Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
Some checks failed
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
Some checks failed
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
This commit is contained in:
321
electron/plugins/browserPluginRuntime.cjs
Normal file
321
electron/plugins/browserPluginRuntime.cjs
Normal file
@@ -0,0 +1,321 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
PLUGIN_ACTIVATION_TIMEOUT_MS,
|
||||
PLUGIN_DEACTIVATION_TIMEOUT_MS,
|
||||
PLUGIN_PROTOCOL_SCHEME,
|
||||
} = require("./constants.cjs");
|
||||
const { PluginRpcRouter } = require("./rpcRouter.cjs");
|
||||
|
||||
function encodePackagePath(packagePath) {
|
||||
return packagePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function waitForLoad(webContents) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
webContents.removeListener("did-finish-load", loaded);
|
||||
webContents.removeListener("did-fail-load", failed);
|
||||
};
|
||||
const loaded = () => { cleanup(); resolve(); };
|
||||
const failed = (_event, code, description) => {
|
||||
cleanup();
|
||||
reject(new Error(`Plugin browser failed to load (${code}): ${description}`));
|
||||
};
|
||||
webContents.once("did-finish-load", loaded);
|
||||
webContents.once("did-fail-load", failed);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeConsoleLevel(level) {
|
||||
if (level === "error" || (typeof level === "number" && level >= 3)) return "error";
|
||||
if (level === "warning" || level === "warn" || level === 2) return "warn";
|
||||
if (level === "debug" || level === 0) return "debug";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function sanitizePluginSource(source) {
|
||||
try { return new URL(String(source)).pathname.slice(0, 512); }
|
||||
catch { return "[plugin resource]"; }
|
||||
}
|
||||
|
||||
class BrowserPluginRuntime {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.protocol = options.protocol;
|
||||
this.plugin = options.plugin;
|
||||
this.packageRoot = options.packageRoot;
|
||||
this.preloadPath = options.preloadPath;
|
||||
this.requestHandlers = options.requestHandlers ?? options.handlers;
|
||||
this.notificationHandlers = options.notificationHandlers ?? options.handlers;
|
||||
this.onIncomingStream = options.onIncomingStream;
|
||||
this.onBeforeMessage = options.onBeforeMessage;
|
||||
this.onProgress = options.onProgress;
|
||||
this.onProcessReady = options.onProcessReady ?? (() => {});
|
||||
this.logger = options.logger ?? { write() {} };
|
||||
this.onExit = options.onExit ?? (() => {});
|
||||
this.onProtocolError = options.onProtocolError ?? (() => {});
|
||||
this.window = null;
|
||||
this.router = null;
|
||||
this.registration = null;
|
||||
this.sessionRegistration = null;
|
||||
this.port = null;
|
||||
this.stopping = false;
|
||||
}
|
||||
|
||||
#assertStarting(signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (this.stopping) throw new Error("Plugin browser runtime startup was stopped");
|
||||
}
|
||||
|
||||
async start(runtimeConfig, options = {}) {
|
||||
const { signal, getActivationEnvironment } = options;
|
||||
this.#assertStarting(signal);
|
||||
const { BrowserWindow, MessageChannelMain, session } = this.electron;
|
||||
if (!BrowserWindow || !MessageChannelMain || !session?.fromPartition) {
|
||||
throw new Error("Electron browser plugin runtime is unavailable");
|
||||
}
|
||||
const partition = `netcatty-plugin-${randomUUID()}`;
|
||||
const pluginSession = session.fromPartition(partition, { cache: false });
|
||||
const config = { ...runtimeConfig };
|
||||
this.registration = this.protocol.registerRuntime({
|
||||
pluginId: this.plugin.id,
|
||||
packageRoot: this.packageRoot,
|
||||
config,
|
||||
});
|
||||
config.entryUrl = `${PLUGIN_PROTOCOL_SCHEME}://${this.registration.token}/package/${encodePackagePath(
|
||||
this.plugin.manifest.main.browser,
|
||||
)}`;
|
||||
this.sessionRegistration = this.protocol.registerSession(pluginSession);
|
||||
await pluginSession.setProxy({
|
||||
mode: "fixed_servers",
|
||||
proxyRules: "http=127.0.0.1:9;https=127.0.0.1:9;socks=127.0.0.1:9",
|
||||
proxyBypassRules: "<-loopback>",
|
||||
});
|
||||
this.#assertStarting(signal);
|
||||
pluginSession.enableNetworkEmulation({ offline: true });
|
||||
pluginSession.on("will-download", (event) => event.preventDefault());
|
||||
pluginSession.setPermissionCheckHandler?.(() => false);
|
||||
pluginSession.setPermissionRequestHandler?.((_webContents, _permission, callback) => callback(false));
|
||||
pluginSession.setDevicePermissionHandler?.(() => false);
|
||||
pluginSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
try {
|
||||
const url = new URL(details.url);
|
||||
const cancel = url.protocol !== `${PLUGIN_PROTOCOL_SCHEME}:` || url.hostname !== this.registration.token;
|
||||
callback({ cancel });
|
||||
} catch {
|
||||
callback({ cancel: true });
|
||||
}
|
||||
});
|
||||
pluginSession.webRequest.onErrorOccurred((details) => {
|
||||
let requestPath = "unknown";
|
||||
try { requestPath = new URL(details.url).pathname.slice(0, 1_024); } catch {}
|
||||
void this.logger.write("error", "Plugin browser resource failed", {
|
||||
error: details.error,
|
||||
requestPath,
|
||||
resourceType: details.resourceType,
|
||||
});
|
||||
});
|
||||
pluginSession.webRequest.onCompleted((details) => {
|
||||
if (details.statusCode < 400) return;
|
||||
let requestPath = "unknown";
|
||||
try { requestPath = new URL(details.url).pathname.slice(0, 1_024); } catch {}
|
||||
void this.logger.write("error", "Plugin browser resource returned an error", {
|
||||
requestPath,
|
||||
resourceType: details.resourceType,
|
||||
statusCode: details.statusCode,
|
||||
});
|
||||
});
|
||||
this.window = new BrowserWindow({
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
paintWhenInitiallyHidden: false,
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
javascript: true,
|
||||
images: false,
|
||||
webgl: false,
|
||||
plugins: false,
|
||||
devTools: false,
|
||||
disableDialogs: true,
|
||||
safeDialogs: true,
|
||||
navigateOnDragDrop: false,
|
||||
backgroundThrottling: false,
|
||||
spellcheck: false,
|
||||
session: pluginSession,
|
||||
preload: this.preloadPath,
|
||||
},
|
||||
});
|
||||
this.onProcessReady();
|
||||
const contents = this.window.webContents;
|
||||
contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp");
|
||||
contents.on("console-message", (details) => {
|
||||
const resolvedMessage = details?.message ?? "Plugin renderer console message";
|
||||
const resolvedLevel = details?.level ?? "info";
|
||||
const logLevel = normalizeConsoleLevel(resolvedLevel);
|
||||
void this.logger.write(logLevel, resolvedMessage, {
|
||||
line: details?.lineNumber ?? 0,
|
||||
source: sanitizePluginSource(details?.sourceId ?? ""),
|
||||
});
|
||||
});
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.on("will-navigate", (event, url) => {
|
||||
if (url !== this.registration.url) event.preventDefault();
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
contents.on("render-process-gone", (_event, details) => {
|
||||
this.#handleExit(new Error(`Plugin renderer exited: ${details.reason}`));
|
||||
});
|
||||
this.window.on("closed", () => this.#handleExit(new Error("Plugin renderer closed")));
|
||||
const { port1, port2 } = new MessageChannelMain();
|
||||
this.port = port1;
|
||||
this.router = new PluginRpcRouter({
|
||||
pluginId: this.plugin.id,
|
||||
send: (message, transfer = []) => port1.postMessage(message, transfer),
|
||||
requestHandlers: this.requestHandlers,
|
||||
notificationHandlers: this.notificationHandlers,
|
||||
onBeforeMessage: this.onBeforeMessage,
|
||||
onIncomingStream: this.onIncomingStream,
|
||||
onProgress: this.onProgress,
|
||||
onProtocolError: (error) => {
|
||||
this.onProtocolError(error);
|
||||
this.#handleExit(error);
|
||||
},
|
||||
});
|
||||
port1.on("message", (event) => this.router?.accept(event.data));
|
||||
port1.on("close", () => this.#handleExit(new Error("Plugin message port closed")));
|
||||
port1.start();
|
||||
let resolvePreloadReady;
|
||||
const preloadReady = new Promise((resolve) => { resolvePreloadReady = resolve; });
|
||||
let resolveRuntimeConnected;
|
||||
const runtimeConnected = new Promise((resolve) => { resolveRuntimeConnected = resolve; });
|
||||
const onPreloadMessage = (_event, channel) => {
|
||||
if (channel === "netcatty-plugin:preload-ready") {
|
||||
void this.logger.write("debug", "Plugin browser preload ready");
|
||||
resolvePreloadReady();
|
||||
}
|
||||
if (channel === "netcatty-plugin:runtime-connected") {
|
||||
void this.logger.write("debug", "Plugin browser message port connected");
|
||||
resolveRuntimeConnected();
|
||||
}
|
||||
};
|
||||
contents.on("ipc-message", onPreloadMessage);
|
||||
const loading = contents.loadURL(this.registration.url);
|
||||
let preloadTimer;
|
||||
const preloadDeadline = new Promise((_, reject) => {
|
||||
preloadTimer = setTimeout(
|
||||
() => reject(new Error("Plugin browser preload did not become ready")),
|
||||
PLUGIN_ACTIVATION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
preloadReady,
|
||||
preloadDeadline,
|
||||
loading.then(() => preloadReady),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(preloadTimer);
|
||||
}
|
||||
this.#assertStarting(signal);
|
||||
contents.postMessage("netcatty-plugin:connect", null, [port2]);
|
||||
let connectionTimer;
|
||||
const connectionDeadline = new Promise((_, reject) => {
|
||||
connectionTimer = setTimeout(
|
||||
() => reject(new Error("Plugin browser runtime did not accept its message port")),
|
||||
PLUGIN_ACTIVATION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.all([
|
||||
loading,
|
||||
Promise.race([runtimeConnected, connectionDeadline]),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(connectionTimer);
|
||||
contents.removeListener("ipc-message", onPreloadMessage);
|
||||
}
|
||||
this.#assertStarting(signal);
|
||||
const initialized = await this.router.request("plugin.initialize", {
|
||||
netcattyVersion: config.netcattyVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
supportedFeatures: config.supportedFeatures,
|
||||
}, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
|
||||
this.#assertStarting(signal);
|
||||
const activationEnvironment = typeof getActivationEnvironment === "function"
|
||||
? getActivationEnvironment()
|
||||
: config.environment;
|
||||
await this.router.request("plugin.activate", activationEnvironment == null
|
||||
? {}
|
||||
: { environment: activationEnvironment }, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
|
||||
this.#assertStarting(signal);
|
||||
return initialized;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopping) return;
|
||||
this.stopping = true;
|
||||
try {
|
||||
await this.router?.request("plugin.deactivate", {}, {
|
||||
timeoutMs: PLUGIN_DEACTIVATION_TIMEOUT_MS,
|
||||
});
|
||||
} finally {
|
||||
const router = this.router;
|
||||
this.router = null;
|
||||
router?.close();
|
||||
this.port?.close();
|
||||
this.registration?.dispose();
|
||||
this.sessionRegistration?.dispose();
|
||||
if (this.window && !this.window.isDestroyed()) this.window.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
request(method, params, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.request(method, params, options);
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
if (!this.router) throw new Error("Plugin browser runtime is not connected");
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
}
|
||||
|
||||
getProcessId() {
|
||||
if (!this.window || this.window.isDestroyed()) return null;
|
||||
return this.window.webContents.getOSProcessId?.() ?? null;
|
||||
}
|
||||
|
||||
#handleExit(error) {
|
||||
if (!this.router) return;
|
||||
const router = this.router;
|
||||
this.router = null;
|
||||
router.close(error);
|
||||
this.port?.close();
|
||||
this.registration?.dispose();
|
||||
this.sessionRegistration?.dispose();
|
||||
if (this.window && !this.window.isDestroyed()) this.window.destroy();
|
||||
const expected = this.stopping;
|
||||
this.onExit({ expected, error });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BrowserPluginRuntime,
|
||||
encodePackagePath,
|
||||
normalizeConsoleLevel,
|
||||
sanitizePluginSource,
|
||||
waitForLoad,
|
||||
};
|
||||
204
electron/plugins/browserPluginRuntime.test.cjs
Normal file
204
electron/plugins/browserPluginRuntime.test.cjs
Normal file
@@ -0,0 +1,204 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
BrowserPluginRuntime,
|
||||
normalizeConsoleLevel,
|
||||
sanitizePluginSource,
|
||||
} = require("./browserPluginRuntime.cjs");
|
||||
|
||||
class FakePort extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.sent = [];
|
||||
this.transferLists = [];
|
||||
}
|
||||
|
||||
postMessage(message, transfer = []) {
|
||||
this.sent.push(message);
|
||||
this.transferLists.push(transfer);
|
||||
if (message.method === "plugin.initialize") {
|
||||
queueMicrotask(() => this.emit("message", { data: {
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
pluginId: "com.example.browser-runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
} }));
|
||||
} else if (message.method === "plugin.activate" || message.method === "plugin.deactivate") {
|
||||
queueMicrotask(() => this.emit("message", { data: {
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: null,
|
||||
} }));
|
||||
}
|
||||
}
|
||||
|
||||
start() {}
|
||||
close() { this.emit("close"); }
|
||||
}
|
||||
|
||||
test("browser host waits for preload and the installed plugin RPC listener", async () => {
|
||||
const contents = new EventEmitter();
|
||||
let resolveLoading;
|
||||
let postedPort = false;
|
||||
contents.loadURL = () => new Promise((resolve) => { resolveLoading = resolve; });
|
||||
contents.postMessage = (_channel, _message, ports) => { postedPort = ports.length === 1; };
|
||||
contents.setWindowOpenHandler = () => {};
|
||||
let webRtcPolicy;
|
||||
contents.setWebRTCIPHandlingPolicy = (policy) => { webRtcPolicy = policy; };
|
||||
const pluginSession = new EventEmitter();
|
||||
pluginSession.protocol = { handle() {}, unhandle() {} };
|
||||
pluginSession.webRequest = {
|
||||
onBeforeRequest() {},
|
||||
onCompleted() {},
|
||||
onErrorOccurred() {},
|
||||
};
|
||||
pluginSession.setPermissionCheckHandler = () => {};
|
||||
pluginSession.setPermissionRequestHandler = () => {};
|
||||
pluginSession.setDevicePermissionHandler = () => {};
|
||||
let proxyOptions;
|
||||
let networkEmulation;
|
||||
pluginSession.setProxy = async (options) => { proxyOptions = options; };
|
||||
pluginSession.enableNetworkEmulation = (options) => { networkEmulation = options; };
|
||||
let windowOptions;
|
||||
class FakeWindow extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
windowOptions = options;
|
||||
this.webContents = contents;
|
||||
}
|
||||
isDestroyed() { return false; }
|
||||
destroy() {}
|
||||
}
|
||||
const port1 = new FakePort();
|
||||
const port2 = new FakePort();
|
||||
const onBeforeMessage = () => {};
|
||||
let processReadyCalls = 0;
|
||||
let sessionDisposed = false;
|
||||
const runtime = new BrowserPluginRuntime({
|
||||
electron: {
|
||||
BrowserWindow: FakeWindow,
|
||||
MessageChannelMain: class { constructor() { this.port1 = port1; this.port2 = port2; } },
|
||||
session: { fromPartition: () => pluginSession },
|
||||
},
|
||||
protocol: {
|
||||
registerSession: () => ({ dispose() { sessionDisposed = true; } }),
|
||||
registerRuntime: () => ({
|
||||
token: "runtime-token",
|
||||
url: "netcatty-plugin://runtime-token/index.html",
|
||||
dispose() {},
|
||||
}),
|
||||
},
|
||||
plugin: {
|
||||
id: "com.example.browser-runtime",
|
||||
manifest: { main: { browser: "dist/index.js" } },
|
||||
},
|
||||
packageRoot: "/plugins/com.example.browser-runtime/1.0.0/package",
|
||||
preloadPath: "/runtime/browserPreload.cjs",
|
||||
handlers: {},
|
||||
onBeforeMessage,
|
||||
onProcessReady: () => { processReadyCalls += 1; },
|
||||
});
|
||||
|
||||
const started = runtime.start({
|
||||
pluginId: "com.example.browser-runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "0.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
enabledFeatures: [],
|
||||
environment: { locale: "en" },
|
||||
}, {
|
||||
getActivationEnvironment: () => ({ locale: "zh-CN", theme: "dark" }),
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(processReadyCalls, 1);
|
||||
contents.emit("ipc-message", {}, "netcatty-plugin:preload-ready");
|
||||
resolveLoading();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(postedPort, true);
|
||||
assert.equal(port1.sent.length, 0, "initialize must wait until the peer installs its RPC listener");
|
||||
|
||||
contents.emit("ipc-message", {}, "netcatty-plugin:runtime-connected");
|
||||
const initialized = await started;
|
||||
assert.equal(initialized.pluginId, "com.example.browser-runtime");
|
||||
assert.equal(runtime.router.onBeforeMessage, onBeforeMessage);
|
||||
assert.deepEqual(port1.sent.map((message) => message.method), ["plugin.initialize", "plugin.activate"]);
|
||||
assert.deepEqual(port1.sent.find((message) => message.method === "plugin.activate").params, {
|
||||
environment: { locale: "zh-CN", theme: "dark" },
|
||||
});
|
||||
const transferable = new ArrayBuffer(8);
|
||||
runtime.router.send({ type: "transfer-test", transferable }, [transferable]);
|
||||
assert.deepEqual(port1.transferLists.at(-1), [transferable]);
|
||||
assert.equal(windowOptions.webPreferences.sandbox, true);
|
||||
assert.equal(windowOptions.webPreferences.nodeIntegration, false);
|
||||
assert.equal(windowOptions.webPreferences.contextIsolation, true);
|
||||
assert.equal(proxyOptions.mode, "fixed_servers");
|
||||
assert.equal(proxyOptions.proxyBypassRules, "<-loopback>");
|
||||
assert.equal(webRtcPolicy, "disable_non_proxied_udp");
|
||||
assert.deepEqual(networkEmulation, { offline: true });
|
||||
let downloadPrevented = false;
|
||||
pluginSession.emit("will-download", { preventDefault() { downloadPrevented = true; } });
|
||||
assert.equal(downloadPrevented, true);
|
||||
await runtime.stop();
|
||||
assert.equal(sessionDisposed, true);
|
||||
assert.doesNotThrow(() => port1.emit("message", { data: {
|
||||
jsonrpc: "2.0",
|
||||
method: "late.notification",
|
||||
} }));
|
||||
});
|
||||
|
||||
test("browser console levels and source paths are normalized without runtime tokens", () => {
|
||||
assert.equal(normalizeConsoleLevel("error"), "error");
|
||||
assert.equal(normalizeConsoleLevel("warning"), "warn");
|
||||
assert.equal(normalizeConsoleLevel("debug"), "debug");
|
||||
assert.equal(normalizeConsoleLevel(3), "error");
|
||||
assert.equal(normalizeConsoleLevel(2), "warn");
|
||||
assert.equal(normalizeConsoleLevel(1), "info");
|
||||
assert.equal(
|
||||
sanitizePluginSource("netcatty-plugin://secret-runtime-token/package/dist/index.js"),
|
||||
"/package/dist/index.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("aborted browser startup cannot create a window after proxy setup resumes", async () => {
|
||||
let releaseProxy;
|
||||
const proxyBlocked = new Promise((resolve) => { releaseProxy = resolve; });
|
||||
const pluginSession = new EventEmitter();
|
||||
pluginSession.protocol = { handle() {}, unhandle() {} };
|
||||
pluginSession.setProxy = () => proxyBlocked;
|
||||
let windows = 0;
|
||||
const runtime = new BrowserPluginRuntime({
|
||||
electron: {
|
||||
BrowserWindow: class { constructor() { windows += 1; } },
|
||||
MessageChannelMain: class {},
|
||||
session: { fromPartition: () => pluginSession },
|
||||
},
|
||||
protocol: {
|
||||
registerSession: () => ({ dispose() {} }),
|
||||
registerRuntime: () => ({ token: "token", dispose() {} }),
|
||||
},
|
||||
plugin: {
|
||||
id: "com.example.cancelled-browser",
|
||||
manifest: { main: { browser: "dist/index.js" } },
|
||||
},
|
||||
packageRoot: "/plugins/cancelled/package",
|
||||
preloadPath: "/runtime/browserPreload.cjs",
|
||||
handlers: {},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const starting = runtime.start({}, { signal: controller.signal });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort(new Error("cancelled"));
|
||||
await runtime.stop();
|
||||
releaseProxy();
|
||||
await assert.rejects(starting, /cancelled/);
|
||||
assert.equal(windows, 0);
|
||||
});
|
||||
639
electron/plugins/companionSupervisor.cjs
Normal file
639
electron/plugins/companionSupervisor.cjs
Normal file
@@ -0,0 +1,639 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { execFile: nodeExecFile, spawn: nodeSpawn } = require("node:child_process");
|
||||
|
||||
const { isPathInside } = require("./paths.cjs");
|
||||
const { canonicalizeCompanionResource } = require("./permissionResources.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_COMPANIONS_PER_RUNTIME = 4;
|
||||
const MAX_COMPANION_PENDING = 64;
|
||||
const MAX_COMPANION_STDERR_BYTES = 64 * 1024;
|
||||
const COMPANION_TERMINATION_GRACE_MS = 500;
|
||||
const COMPANION_CONTAINMENT_TIMEOUT_MS = 2_000;
|
||||
const PROCESS_TREE_POLL_MS = 25;
|
||||
|
||||
function platformKey(platform = process.platform, arch = process.arch) {
|
||||
return `${platform}-${arch}`;
|
||||
}
|
||||
|
||||
function assertCompanionId(value) {
|
||||
try {
|
||||
return canonicalizeCompanionResource(value);
|
||||
} catch {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion ID is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertHandleId(value) {
|
||||
if (typeof value !== "string" || value.length < 16 || value.length > 128) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion handle is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertOperationId(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 128
|
||||
|| value.includes("\0")
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion operation ID is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCompanionMethod(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 256
|
||||
|| value.startsWith("$/")
|
||||
|| !/^[A-Za-z][A-Za-z0-9_.-]*$/u.test(value)
|
||||
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion RPC method is invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertAdvancedCompanionContext(context) {
|
||||
if (context?.runtimeKind !== "utility" || typeof context.manifest?.main?.node !== "string") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin companions require an advanced utility runtime",
|
||||
{ pluginCode: "permission_denied" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rpcIdKey(id) {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function waitUntilStopped(isAlive, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (isAlive()) {
|
||||
if (Date.now() >= deadline) return false;
|
||||
await delay(Math.min(PROCESS_TREE_POLL_MS, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPosixProcessGroupAlive(pid, kill = process.kill) {
|
||||
try {
|
||||
kill(-pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") return false;
|
||||
if (error?.code === "EPERM") return true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function signalPosixProcessGroup(pid, signal, kill = process.kill) {
|
||||
try {
|
||||
kill(-pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runTaskkill(pid, force, execFile = nodeExecFile) {
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const executable = path.join(systemRoot, "System32", "taskkill.exe");
|
||||
const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])];
|
||||
return new Promise((resolve) => {
|
||||
execFile(executable, args, {
|
||||
windowsHide: true,
|
||||
timeout: force ? COMPANION_CONTAINMENT_TIMEOUT_MS : COMPANION_TERMINATION_GRACE_MS,
|
||||
}, (error) => resolve(error == null));
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateDirectChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
try { child.kill(); } catch {}
|
||||
if (await waitUntilStopped(
|
||||
() => child.exitCode === null && child.signalCode === null,
|
||||
COMPANION_TERMINATION_GRACE_MS,
|
||||
)) return;
|
||||
try { child.kill("SIGKILL"); } catch {}
|
||||
if (await waitUntilStopped(
|
||||
() => child.exitCode === null && child.signalCode === null,
|
||||
COMPANION_CONTAINMENT_TIMEOUT_MS - COMPANION_TERMINATION_GRACE_MS,
|
||||
)) return;
|
||||
throw new Error("Plugin companion direct process did not exit");
|
||||
}
|
||||
|
||||
async function terminateCompanionProcessTree(child, options = {}) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const pid = child?.pid;
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
await terminateDirectChild(child);
|
||||
return;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const graceful = await runTaskkill(pid, false, options.execFile);
|
||||
await (options.delay ?? delay)(COMPANION_TERMINATION_GRACE_MS);
|
||||
const forced = await runTaskkill(pid, true, options.execFile);
|
||||
if (!graceful && !forced && child.exitCode === null && child.signalCode === null) {
|
||||
throw new Error("Plugin companion process tree could not be terminated");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kill = options.kill ?? process.kill;
|
||||
signalPosixProcessGroup(pid, "SIGTERM", kill);
|
||||
if (await waitUntilStopped(
|
||||
() => isPosixProcessGroupAlive(pid, kill),
|
||||
COMPANION_TERMINATION_GRACE_MS,
|
||||
)) return;
|
||||
signalPosixProcessGroup(pid, "SIGKILL", kill);
|
||||
if (await waitUntilStopped(
|
||||
() => isPosixProcessGroupAlive(pid, kill),
|
||||
COMPANION_CONTAINMENT_TIMEOUT_MS - COMPANION_TERMINATION_GRACE_MS,
|
||||
)) return;
|
||||
throw new Error("Plugin companion process group could not be reaped");
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const hash = createHash("sha256");
|
||||
const input = fs.createReadStream(filePath);
|
||||
for await (const chunk of input) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function resolveCompanionVariant(identity, companionId, targetPlatform = platformKey()) {
|
||||
const companion = identity.manifest.companionExecutables?.find(({ id }) => id === companionId);
|
||||
if (!companion) throw new PluginRpcError(RPC_ERRORS.notFound, "Plugin companion is not declared");
|
||||
const variant = companion.variants.find(({ platforms }) => platforms.includes(targetPlatform));
|
||||
if (!variant) throw new PluginRpcError(RPC_ERRORS.unsupported, "Plugin companion has no compatible binary");
|
||||
const candidate = path.resolve(identity.packageRoot, ...variant.path.split("/"));
|
||||
const [realRoot, realCandidate] = await Promise.all([
|
||||
fsp.realpath(identity.packageRoot),
|
||||
fsp.realpath(candidate),
|
||||
]);
|
||||
if (!isPathInside(realRoot, realCandidate)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin companion escapes its package");
|
||||
}
|
||||
const stats = await fsp.lstat(realCandidate);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin companion is not a regular file");
|
||||
}
|
||||
const digest = await sha256File(realCandidate);
|
||||
if (digest !== variant.sha256) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin companion digest mismatch");
|
||||
}
|
||||
return { companion, variant, executablePath: realCandidate };
|
||||
}
|
||||
|
||||
class CompanionRpcPeer {
|
||||
constructor(options) {
|
||||
this.child = options.child;
|
||||
this.contract = options.contract;
|
||||
this.onProtocolError = options.onProtocolError;
|
||||
this.pending = new Map();
|
||||
this.retiredResponseIds = new Set();
|
||||
this.nextId = 0;
|
||||
this.closed = false;
|
||||
this.decoder = new this.contract.ContentLengthFrameDecoder();
|
||||
this.child.stdout.on("data", (chunk) => this.#acceptBytes(chunk));
|
||||
this.child.stdout.on("end", () => {
|
||||
try { this.decoder.finish(); }
|
||||
catch (error) { this.#fail(error); }
|
||||
});
|
||||
}
|
||||
|
||||
#send(value) {
|
||||
if (this.closed || !this.child.stdin.writable) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin companion is closed");
|
||||
}
|
||||
const frame = this.contract.encodeContentLengthFrame(value);
|
||||
this.child.stdin.write(frame);
|
||||
}
|
||||
|
||||
#acceptBytes(chunk) {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
for (const message of this.decoder.push(chunk)) this.#accept(message);
|
||||
} catch (error) {
|
||||
this.#fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
#accept(message) {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message) || message.jsonrpc !== "2.0") {
|
||||
throw new Error("Plugin companion returned a non-RPC message");
|
||||
}
|
||||
if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
|
||||
if (Object.hasOwn(message, "result") === Object.hasOwn(message, "error")) {
|
||||
throw new Error("Plugin companion response must contain exactly one result or error");
|
||||
}
|
||||
const key = rpcIdKey(message.id);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) {
|
||||
if (this.retiredResponseIds.delete(key)) return;
|
||||
throw new Error("Plugin companion returned an unknown response ID");
|
||||
}
|
||||
this.pending.delete(key);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.error) {
|
||||
pending.reject(new PluginRpcError(
|
||||
Number.isInteger(message.error.code) ? message.error.code : RPC_ERRORS.internal,
|
||||
typeof message.error.message === "string" ? message.error.message.slice(0, 2_048) : "Companion request failed",
|
||||
message.error.data,
|
||||
));
|
||||
} else pending.resolve(message.result ?? null);
|
||||
return;
|
||||
}
|
||||
if (Object.hasOwn(message, "id")) {
|
||||
this.#send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32601, message: "Companion-to-host methods are not exposed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#allocateRequestId() {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt <= this.pending.size + this.retiredResponseIds.size;
|
||||
attempt += 1
|
||||
) {
|
||||
const id = this.nextId;
|
||||
this.nextId = this.nextId === Number.MAX_SAFE_INTEGER ? 0 : this.nextId + 1;
|
||||
const key = rpcIdKey(id);
|
||||
if (!this.pending.has(key) && !this.retiredResponseIds.has(key)) return id;
|
||||
}
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "No companion RPC correlation ID is available");
|
||||
}
|
||||
|
||||
#retireResponseId(key) {
|
||||
this.retiredResponseIds.add(key);
|
||||
while (this.retiredResponseIds.size > MAX_COMPANION_PENDING) {
|
||||
this.retiredResponseIds.delete(this.retiredResponseIds.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
request(method, params, timeoutMs) {
|
||||
if (this.closed) return Promise.reject(new PluginRpcError(RPC_ERRORS.unavailable, "Plugin companion is closed"));
|
||||
if (this.pending.size >= MAX_COMPANION_PENDING) {
|
||||
return Promise.reject(new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many companion requests"));
|
||||
}
|
||||
const id = this.#allocateRequestId();
|
||||
const key = rpcIdKey(id);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(key);
|
||||
this.#retireResponseId(key);
|
||||
reject(new PluginRpcError(RPC_ERRORS.deadlineExceeded, `Companion request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(key, { resolve, reject, timer });
|
||||
try {
|
||||
this.#send({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) });
|
||||
} catch (error) {
|
||||
this.pending.delete(key);
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#fail(error) {
|
||||
if (this.closed) return;
|
||||
this.close(error);
|
||||
this.onProtocolError(error);
|
||||
}
|
||||
|
||||
close(error = new PluginRpcError(RPC_ERRORS.unavailable, "Plugin companion closed")) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.retiredResponseIds.clear();
|
||||
try { this.child.stdin.end(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
class PluginCompanionSupervisor {
|
||||
constructor(options) {
|
||||
this.paths = options.paths;
|
||||
this.spawn = options.spawn ?? nodeSpawn;
|
||||
this.platform = options.platform ?? process.platform;
|
||||
this.arch = options.arch ?? process.arch;
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.onContainmentFailure = options.onContainmentFailure ?? (() => {});
|
||||
this.terminateProcessTree = options.terminateProcessTree
|
||||
?? ((child) => terminateCompanionProcessTree(child, { platform: this.platform }));
|
||||
this.handles = new Map();
|
||||
this.startingCounts = new Map();
|
||||
this.contractPromise = null;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
#loadContract() {
|
||||
this.contractPromise ??= import("@netcatty/plugin-contract");
|
||||
return this.contractPromise;
|
||||
}
|
||||
|
||||
validateStart(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion start parameters are invalid");
|
||||
}
|
||||
return { companionId: assertCompanionId(params.companionId) };
|
||||
}
|
||||
|
||||
validateRequest(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion request parameters are invalid");
|
||||
}
|
||||
const timeoutMs = params.timeoutMs ?? 30_000;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion request timeout is invalid");
|
||||
}
|
||||
let credentialLeases;
|
||||
let operationId;
|
||||
if (params.credentialLeases !== undefined) {
|
||||
if (!params.credentialLeases || typeof params.credentialLeases !== "object"
|
||||
|| Array.isArray(params.credentialLeases)
|
||||
|| Object.keys(params.credentialLeases).length < 1
|
||||
|| Object.keys(params.credentialLeases).length > 16) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion credential leases are invalid");
|
||||
}
|
||||
operationId = assertOperationId(params.operationId);
|
||||
credentialLeases = {};
|
||||
for (const [name, lease] of Object.entries(params.credentialLeases)) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/u.test(name)
|
||||
|| !lease || typeof lease !== "object" || Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string" || lease.id.length < 16 || lease.id.length > 256) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion credential lease is invalid");
|
||||
}
|
||||
credentialLeases[name] = Object.freeze({ kind: "secret-lease", id: lease.id });
|
||||
}
|
||||
Object.freeze(credentialLeases);
|
||||
}
|
||||
return {
|
||||
handleId: assertHandleId(params.handleId),
|
||||
method: assertCompanionMethod(params.method),
|
||||
...(params.params === undefined ? {} : { params: params.params }),
|
||||
...(credentialLeases === undefined ? {} : { credentialLeases, operationId }),
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
validateStop(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin companion stop parameters are invalid");
|
||||
}
|
||||
return { handleId: assertHandleId(params.handleId) };
|
||||
}
|
||||
|
||||
describeStartAuthorization(params, context) {
|
||||
assertAdvancedCompanionContext(context);
|
||||
const value = this.validateStart(params);
|
||||
return {
|
||||
permission: "companion.execute",
|
||||
resources: [value.companionId],
|
||||
reason: `Launch companion ${value.companionId}`,
|
||||
operationId: `companion.start:${value.companionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
describeHandleAuthorization(params, context) {
|
||||
const handleId = assertHandleId(params?.handleId);
|
||||
const record = this.#ownedRecord(handleId, context);
|
||||
return {
|
||||
permission: "companion.execute",
|
||||
resources: [record.companionId],
|
||||
reason: `Use companion ${record.companionId}`,
|
||||
operationId: `companion:${handleId}`,
|
||||
};
|
||||
}
|
||||
|
||||
#reserveStart(runtimeId) {
|
||||
const activeCount = [...this.handles.values()].filter((record) => (
|
||||
record.runtimeId === runtimeId
|
||||
)).length;
|
||||
const startingCount = this.startingCounts.get(runtimeId) ?? 0;
|
||||
if (activeCount + startingCount >= MAX_COMPANIONS_PER_RUNTIME) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin companion process quota exceeded");
|
||||
}
|
||||
this.startingCounts.set(runtimeId, startingCount + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (this.startingCounts.get(runtimeId) ?? 1) - 1;
|
||||
if (remaining <= 0) this.startingCounts.delete(runtimeId);
|
||||
else this.startingCounts.set(runtimeId, remaining);
|
||||
};
|
||||
}
|
||||
|
||||
async start(params, context) {
|
||||
if (this.closed) throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin companion supervisor is closed");
|
||||
assertAdvancedCompanionContext(context);
|
||||
const { companionId } = this.validateStart(params);
|
||||
const releaseReservation = this.#reserveStart(context.runtimeId);
|
||||
return this.#startReserved(companionId, context).finally(releaseReservation);
|
||||
}
|
||||
|
||||
async #startReserved(companionId, context) {
|
||||
const dataDirectory = path.join(this.paths.data, context.pluginId);
|
||||
await fsp.mkdir(dataDirectory, { recursive: true, mode: 0o700 });
|
||||
const contract = await this.#loadContract();
|
||||
await context.assertActive();
|
||||
const resolved = await resolveCompanionVariant(
|
||||
context,
|
||||
companionId,
|
||||
platformKey(this.platform, this.arch),
|
||||
);
|
||||
context.signal?.throwIfAborted();
|
||||
const child = this.spawn(resolved.executablePath, [], {
|
||||
cwd: dataDirectory,
|
||||
env: {
|
||||
LANG: process.env.LANG ?? "C.UTF-8",
|
||||
LC_ALL: process.env.LC_ALL ?? "",
|
||||
},
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
detached: this.platform !== "win32",
|
||||
});
|
||||
const handleId = randomUUID();
|
||||
const record = {
|
||||
handleId,
|
||||
companionId,
|
||||
pluginId: context.pluginId,
|
||||
pluginVersion: context.pluginVersion,
|
||||
runtimeId: context.runtimeId,
|
||||
runtimeKind: context.runtimeKind,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
child,
|
||||
peer: null,
|
||||
quotaResourceId: `${context.runtimeId}\0companion:${handleId}`,
|
||||
stopping: false,
|
||||
stderrBytes: 0,
|
||||
};
|
||||
const peer = new CompanionRpcPeer({
|
||||
child,
|
||||
contract,
|
||||
onProtocolError: () => { void this.#stopRecord(record); },
|
||||
});
|
||||
record.peer = peer;
|
||||
this.handles.set(handleId, record);
|
||||
child.stderr.on("data", (chunk) => {
|
||||
record.stderrBytes += chunk.byteLength;
|
||||
if (record.stderrBytes > MAX_COMPANION_STDERR_BYTES) void this.#stopRecord(record);
|
||||
});
|
||||
child.once("exit", () => {
|
||||
peer.close();
|
||||
if (!record.stopping) void this.#stopRecord(record).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const onSpawn = () => {
|
||||
child.removeListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (error) => {
|
||||
child.removeListener("spawn", onSpawn);
|
||||
reject(error);
|
||||
};
|
||||
child.once("spawn", onSpawn);
|
||||
child.once("error", onError);
|
||||
});
|
||||
} catch (error) {
|
||||
peer.close();
|
||||
this.handles.delete(handleId);
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
`Plugin companion failed to start: ${error?.message ?? String(error)}`,
|
||||
);
|
||||
}
|
||||
this.quotaManager?.trackProcess?.(
|
||||
record.quotaResourceId,
|
||||
Object.freeze({
|
||||
pluginId: record.pluginId,
|
||||
pluginVersion: record.pluginVersion,
|
||||
runtimeId: record.runtimeId,
|
||||
runtimeKind: record.runtimeKind,
|
||||
securityPrincipal: record.securityPrincipal,
|
||||
}),
|
||||
{ getProcessId: () => child.pid },
|
||||
);
|
||||
child.once("error", () => { void this.#stopRecord(record); });
|
||||
try {
|
||||
await context.assertActive();
|
||||
} catch (error) {
|
||||
await this.#stopRecord(record);
|
||||
throw error;
|
||||
}
|
||||
return { handleId };
|
||||
}
|
||||
|
||||
#ownedRecord(handleId, context) {
|
||||
const record = this.handles.get(handleId);
|
||||
if (!record || record.pluginId !== context.pluginId || record.runtimeId !== context.runtimeId) {
|
||||
throw new PluginRpcError(RPC_ERRORS.notFound, "Plugin companion handle was not found");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async request(params, context) {
|
||||
const value = this.validateRequest(params);
|
||||
const record = this.#ownedRecord(value.handleId, context);
|
||||
this.quotaManager?.chargeBytes(
|
||||
context.runtimeId,
|
||||
"companion",
|
||||
Buffer.byteLength(JSON.stringify(value.params ?? null)),
|
||||
);
|
||||
const result = await record.peer.request(value.method, value.params, value.timeoutMs);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "companion", Buffer.byteLength(JSON.stringify(result)));
|
||||
await context.assertActive();
|
||||
return result;
|
||||
}
|
||||
|
||||
async stop(params, context) {
|
||||
const value = this.validateStop(params);
|
||||
await this.#stopRecord(this.#ownedRecord(value.handleId, context));
|
||||
return null;
|
||||
}
|
||||
|
||||
async #stopRecord(record) {
|
||||
if (record.stopping) return record.stopPromise;
|
||||
record.stopping = true;
|
||||
record.stopPromise = Promise.resolve().then(() => this.terminateProcessTree(record.child)).then(() => {
|
||||
record.peer.close();
|
||||
this.quotaManager?.releaseProcess?.(record.quotaResourceId);
|
||||
this.handles.delete(record.handleId);
|
||||
}, async (error) => {
|
||||
const containmentError = new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
`Plugin companion could not be reaped: ${record.companionId}`,
|
||||
{ cause: error?.message ?? String(error) },
|
||||
);
|
||||
record.peer.close(containmentError);
|
||||
record.stopping = false;
|
||||
record.stopPromise = null;
|
||||
await this.onContainmentFailure(Object.freeze({
|
||||
pluginId: record.pluginId,
|
||||
pluginVersion: record.pluginVersion,
|
||||
runtimeId: record.runtimeId,
|
||||
runtimeKind: record.runtimeKind,
|
||||
securityPrincipal: record.securityPrincipal,
|
||||
}), containmentError);
|
||||
throw containmentError;
|
||||
});
|
||||
return record.stopPromise;
|
||||
}
|
||||
|
||||
async releaseRuntime(runtimeId) {
|
||||
const results = await Promise.allSettled([...this.handles.values()]
|
||||
.filter((record) => record.runtimeId === runtimeId)
|
||||
.map((record) => this.#stopRecord(record)));
|
||||
const failures = results
|
||||
.filter((result) => result.status === "rejected")
|
||||
.map((result) => result.reason);
|
||||
if (failures.length === 1) throw failures[0];
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, `Plugin companion cleanup failed for runtime: ${runtimeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.closed = true;
|
||||
await Promise.allSettled([...this.handles.values()].map((record) => this.#stopRecord(record)));
|
||||
this.startingCounts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMPANION_CONTAINMENT_TIMEOUT_MS,
|
||||
COMPANION_TERMINATION_GRACE_MS,
|
||||
CompanionRpcPeer,
|
||||
MAX_COMPANIONS_PER_RUNTIME,
|
||||
MAX_COMPANION_PENDING,
|
||||
PluginCompanionSupervisor,
|
||||
assertCompanionId,
|
||||
assertCompanionMethod,
|
||||
platformKey,
|
||||
resolveCompanionVariant,
|
||||
rpcIdKey,
|
||||
isPosixProcessGroupAlive,
|
||||
signalPosixProcessGroup,
|
||||
sha256File,
|
||||
terminateCompanionProcessTree,
|
||||
};
|
||||
420
electron/plugins/companionSupervisor.test.cjs
Normal file
420
electron/plugins/companionSupervisor.test.cjs
Normal file
@@ -0,0 +1,420 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PassThrough } = require("node:stream");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
CompanionRpcPeer,
|
||||
PluginCompanionSupervisor,
|
||||
terminateCompanionProcessTree,
|
||||
} = require("./companionSupervisor.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function createRoot(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-companion-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
function runtimeContext(packageRoot, digest, overrides = {}) {
|
||||
return {
|
||||
pluginId: "com.example.companion",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "unsigned-package:test",
|
||||
packageRoot,
|
||||
manifest: {
|
||||
main: { node: "dist/node.js" },
|
||||
permissions: {
|
||||
required: [
|
||||
"runtime.advanced",
|
||||
{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.companion.helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.companion.helper",
|
||||
variants: [{ path: "bin/helper", platforms: [`${process.platform}-${process.arch}`], sha256: digest }],
|
||||
}],
|
||||
},
|
||||
assertActive: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("companion requests accept only the public secret-lease reference shape", () => {
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(os.tmpdir(), "netcatty-plugin-companion-validation") },
|
||||
});
|
||||
const params = {
|
||||
handleId: "companion-handle-0001",
|
||||
method: "authenticate",
|
||||
credentialLeases: {
|
||||
password: { kind: "secret-lease", id: "credential-lease-password" },
|
||||
},
|
||||
operationId: "login",
|
||||
};
|
||||
|
||||
assert.deepEqual(supervisor.validateRequest(params), {
|
||||
...params,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
assert.throws(
|
||||
() => supervisor.validateRequest({
|
||||
...params,
|
||||
credentialLeases: {
|
||||
password: { kind: "secretLease", id: "credential-lease-password" },
|
||||
},
|
||||
}),
|
||||
(error) => error.code === RPC_ERRORS.invalidArgument,
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary browser runtimes cannot authorize or launch native companions", async (context) => {
|
||||
const root = createRoot(context);
|
||||
let spawnCalls = 0;
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(root, "data") },
|
||||
spawn: () => {
|
||||
spawnCalls += 1;
|
||||
throw new Error("browser companion must never spawn");
|
||||
},
|
||||
});
|
||||
const browser = runtimeContext(root, "0".repeat(64), {
|
||||
runtimeKind: "browser",
|
||||
manifest: {
|
||||
main: { browser: "dist/browser.js" },
|
||||
permissions: {
|
||||
required: [{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.companion.helper"],
|
||||
}],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.companion.helper",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: [`${process.platform}-${process.arch}`],
|
||||
sha256: "0".repeat(64),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => supervisor.describeStartAuthorization({
|
||||
companionId: "com.example.companion.helper",
|
||||
}, browser),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
await assert.rejects(
|
||||
supervisor.start({ companionId: "com.example.companion.helper" }, browser),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(spawnCalls, 0);
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
constructor(contract, responseId = (id) => id) {
|
||||
super();
|
||||
this.stdin = new PassThrough();
|
||||
this.stdout = new PassThrough();
|
||||
this.stderr = new PassThrough();
|
||||
this.exitCode = null;
|
||||
this.signalCode = null;
|
||||
const decoder = new contract.ContentLengthFrameDecoder();
|
||||
this.stdin.on("data", (chunk) => {
|
||||
for (const message of decoder.push(chunk)) {
|
||||
if (!Object.hasOwn(message, "id")) continue;
|
||||
queueMicrotask(() => this.stdout.write(contract.encodeContentLengthFrame({
|
||||
jsonrpc: "2.0",
|
||||
id: responseId(message.id),
|
||||
result: { echoed: message.params ?? null },
|
||||
})));
|
||||
}
|
||||
});
|
||||
queueMicrotask(() => this.emit("spawn"));
|
||||
}
|
||||
|
||||
kill(signal = "SIGTERM") {
|
||||
this.signalCode = signal;
|
||||
queueMicrotask(() => this.emit("exit", null, signal));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class ManualChild extends EventEmitter {
|
||||
constructor(contract) {
|
||||
super();
|
||||
this.stdin = new PassThrough();
|
||||
this.stdout = new PassThrough();
|
||||
this.stderr = new PassThrough();
|
||||
this.exitCode = null;
|
||||
this.signalCode = null;
|
||||
this.messages = [];
|
||||
const decoder = new contract.ContentLengthFrameDecoder();
|
||||
this.stdin.on("data", (chunk) => this.messages.push(...decoder.push(chunk)));
|
||||
}
|
||||
|
||||
respond(contract, id, result) {
|
||||
this.stdout.write(contract.encodeContentLengthFrame({ jsonrpc: "2.0", id, result }));
|
||||
}
|
||||
}
|
||||
|
||||
test("companion RPC correlation keeps numeric and string IDs distinct", async () => {
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const child = new FakeChild(contract, (id) => String(id));
|
||||
const errors = [];
|
||||
const peer = new CompanionRpcPeer({
|
||||
child,
|
||||
contract,
|
||||
onProtocolError: (error) => errors.push(error.message),
|
||||
});
|
||||
await assert.rejects(peer.request("echo", null, 1_000), /unknown response ID/);
|
||||
assert.deepEqual(errors, ["Plugin companion returned an unknown response ID"]);
|
||||
});
|
||||
|
||||
test("companion RPC ignores one late timed-out response and never reuses its ID", async () => {
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const child = new ManualChild(contract);
|
||||
const errors = [];
|
||||
const peer = new CompanionRpcPeer({
|
||||
child,
|
||||
contract,
|
||||
onProtocolError: (error) => errors.push(error.message),
|
||||
});
|
||||
await assert.rejects(
|
||||
peer.request("slow", null, 5),
|
||||
(error) => error.code === RPC_ERRORS.deadlineExceeded,
|
||||
);
|
||||
assert.equal(child.messages[0].id, 0);
|
||||
peer.nextId = 0;
|
||||
const next = peer.request("next", { value: 1 }, 1_000);
|
||||
assert.equal(child.messages[1].id, 1);
|
||||
child.respond(contract, 0, { late: true });
|
||||
child.respond(contract, 1, { ok: true });
|
||||
assert.deepEqual(await next, { ok: true });
|
||||
assert.deepEqual(errors, []);
|
||||
peer.close();
|
||||
});
|
||||
|
||||
test("companion launch verifies digest immediately and never spawns a mismatch", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const packageRoot = path.join(root, "package");
|
||||
const executable = path.join(packageRoot, "bin/helper");
|
||||
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
||||
await fsp.writeFile(executable, "binary");
|
||||
let spawnCalls = 0;
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(root, "data") },
|
||||
spawn: () => { spawnCalls += 1; throw new Error("must not spawn"); },
|
||||
});
|
||||
await assert.rejects(
|
||||
supervisor.start(
|
||||
{ companionId: "com.example.companion.helper" },
|
||||
runtimeContext(packageRoot, "0".repeat(64)),
|
||||
),
|
||||
(error) => error.code === RPC_ERRORS.dataLoss,
|
||||
);
|
||||
assert.equal(spawnCalls, 0);
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("companion runs shell-free with sanitized environment and bounded host-only RPC", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const packageRoot = path.join(root, "package");
|
||||
const executable = path.join(packageRoot, "bin/helper");
|
||||
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
||||
const contents = Buffer.from("binary");
|
||||
await fsp.writeFile(executable, contents);
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const spawns = [];
|
||||
const tracked = [];
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(root, "data") },
|
||||
spawn: (command, args, options) => {
|
||||
const child = new FakeChild(contract);
|
||||
spawns.push({ command, args, options, child });
|
||||
return child;
|
||||
},
|
||||
quotaManager: {
|
||||
trackProcess: (resourceId, identity) => tracked.push({ resourceId, identity }),
|
||||
releaseProcess() {},
|
||||
chargeBytes() {},
|
||||
},
|
||||
});
|
||||
const runtime = runtimeContext(packageRoot, digest);
|
||||
const handle = await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
||||
assert.equal(spawns[0].command, await fsp.realpath(executable));
|
||||
assert.deepEqual(spawns[0].args, []);
|
||||
assert.equal(spawns[0].options.shell, false);
|
||||
assert.equal(spawns[0].options.detached, process.platform !== "win32");
|
||||
assert.deepEqual(Object.keys(spawns[0].options.env).sort(), ["LANG", "LC_ALL"]);
|
||||
assert.deepEqual(tracked, [{
|
||||
resourceId: `${runtime.runtimeId}\0companion:${handle.handleId}`,
|
||||
identity: {
|
||||
pluginId: runtime.pluginId,
|
||||
pluginVersion: runtime.pluginVersion,
|
||||
runtimeId: runtime.runtimeId,
|
||||
runtimeKind: runtime.runtimeKind,
|
||||
securityPrincipal: runtime.securityPrincipal,
|
||||
},
|
||||
}]);
|
||||
|
||||
assert.deepEqual(await supervisor.request({
|
||||
handleId: handle.handleId,
|
||||
method: "echo",
|
||||
params: { value: 1 },
|
||||
}, runtime), { echoed: { value: 1 } });
|
||||
await assert.rejects(
|
||||
supervisor.request({ handleId: handle.handleId, method: "echo" }, {
|
||||
...runtime,
|
||||
runtimeId: "runtime-2",
|
||||
}),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
await supervisor.stop({ handleId: handle.handleId }, runtime);
|
||||
assert.equal(spawns[0].child.signalCode, "SIGTERM");
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("companion cleanup reports the complete runtime identity and rejects containment failure", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const packageRoot = path.join(root, "package");
|
||||
const executable = path.join(packageRoot, "bin/helper");
|
||||
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
||||
const contents = Buffer.from("binary");
|
||||
await fsp.writeFile(executable, contents);
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const failures = [];
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(root, "data") },
|
||||
spawn: () => new FakeChild(contract),
|
||||
terminateProcessTree: async () => { throw new Error("process tree survived"); },
|
||||
onContainmentFailure: (identity, error) => failures.push({ identity, error }),
|
||||
});
|
||||
const runtime = runtimeContext(packageRoot, digest);
|
||||
await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
||||
|
||||
await assert.rejects(
|
||||
supervisor.releaseRuntime(runtime.runtimeId),
|
||||
(error) => error.code === RPC_ERRORS.failedPrecondition,
|
||||
);
|
||||
assert.deepEqual(failures.map(({ identity }) => identity), [{
|
||||
pluginId: runtime.pluginId,
|
||||
pluginVersion: runtime.pluginVersion,
|
||||
runtimeId: runtime.runtimeId,
|
||||
runtimeKind: runtime.runtimeKind,
|
||||
securityPrincipal: runtime.securityPrincipal,
|
||||
}]);
|
||||
assert.match(failures[0].error.message, /could not be reaped/);
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("Windows companion termination uses shell-free tree cleanup for both stages", async () => {
|
||||
const calls = [];
|
||||
await terminateCompanionProcessTree({
|
||||
pid: 1234,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
}, {
|
||||
platform: "win32",
|
||||
delay: async () => {},
|
||||
execFile(executable, args, options, callback) {
|
||||
calls.push({ executable, args, options });
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0].args, ["/PID", "1234", "/T"]);
|
||||
assert.deepEqual(calls[1].args, ["/PID", "1234", "/T", "/F"]);
|
||||
assert.equal(calls[0].options.windowsHide, true);
|
||||
});
|
||||
|
||||
test("companion stop reaps its POSIX descendant process group", {
|
||||
skip: process.platform === "win32" || /\s/u.test(process.execPath),
|
||||
}, async (context) => {
|
||||
const root = createRoot(context);
|
||||
const packageRoot = path.join(root, "package");
|
||||
const executable = path.join(packageRoot, "bin/helper");
|
||||
const dataDirectory = path.join(root, "data", "com.example.companion");
|
||||
const pidFile = path.join(dataDirectory, "descendant.pid");
|
||||
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
||||
const contents = Buffer.from(`#!${process.execPath}\n`
|
||||
+ `const fs = require("node:fs");\n`
|
||||
+ `const { spawn } = require("node:child_process");\n`
|
||||
+ `process.on("SIGTERM", () => {});\n`
|
||||
+ `const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });\n`
|
||||
+ `fs.writeFileSync("descendant.pid", String(child.pid));\n`
|
||||
+ `setInterval(() => {}, 1000);\n`);
|
||||
await fsp.writeFile(executable, contents, { mode: 0o755 });
|
||||
await fsp.chmod(executable, 0o755);
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const supervisor = new PluginCompanionSupervisor({ paths: { data: path.join(root, "data") } });
|
||||
const runtime = runtimeContext(packageRoot, digest);
|
||||
const handle = await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
||||
let descendantPid;
|
||||
// The complete plugin suite runs many test files in parallel, so process
|
||||
// scheduling can exceed one second on a busy CI host. Treat the PID file as
|
||||
// the explicit readiness barrier and keep a bounded ten-second deadline.
|
||||
for (let attempt = 0; attempt < 1_000; attempt += 1) {
|
||||
try {
|
||||
descendantPid = Number(await fsp.readFile(pidFile, "utf8"));
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
assert.ok(Number.isSafeInteger(descendantPid) && descendantPid > 0);
|
||||
context.after(() => {
|
||||
if (!descendantPid) return;
|
||||
try { process.kill(descendantPid, "SIGKILL"); } catch {}
|
||||
});
|
||||
await supervisor.stop({ handleId: handle.handleId }, runtime);
|
||||
await assert.rejects(
|
||||
Promise.resolve().then(() => process.kill(descendantPid, 0)),
|
||||
(error) => error?.code === "ESRCH",
|
||||
);
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("concurrent companion starts reserve the per-runtime process quota", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const packageRoot = path.join(root, "package");
|
||||
const executable = path.join(packageRoot, "bin/helper");
|
||||
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
||||
const contents = Buffer.from("binary");
|
||||
await fsp.writeFile(executable, contents);
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
let spawns = 0;
|
||||
const supervisor = new PluginCompanionSupervisor({
|
||||
paths: { data: path.join(root, "data") },
|
||||
spawn: () => {
|
||||
spawns += 1;
|
||||
return new FakeChild(contract);
|
||||
},
|
||||
});
|
||||
const runtime = runtimeContext(packageRoot, digest);
|
||||
const results = await Promise.allSettled(Array.from({ length: 5 }, () => (
|
||||
supervisor.start({ companionId: "com.example.companion.helper" }, runtime)
|
||||
)));
|
||||
assert.equal(results.filter(({ status }) => status === "fulfilled").length, 4);
|
||||
assert.equal(results.filter(({ status }) => status === "rejected").length, 1);
|
||||
assert.equal(results.find(({ status }) => status === "rejected").reason.code, RPC_ERRORS.resourceExhausted);
|
||||
assert.equal(spawns, 4);
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
45
electron/plugins/constants.cjs
Normal file
45
electron/plugins/constants.cjs
Normal file
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
const PLUGIN_DEV_ENV = "NETCATTY_PLUGIN_DEV";
|
||||
const PLUGIN_PROTOCOL_SCHEME = "netcatty-plugin";
|
||||
const PLUGIN_API_VERSION = "0.1.0-internal";
|
||||
const PLUGIN_ACTIVATION_TIMEOUT_MS = 5_000;
|
||||
const PLUGIN_DEACTIVATION_TIMEOUT_MS = 2_000;
|
||||
const PLUGIN_UTILITY_TERMINATION_GRACE_MS = 500;
|
||||
const PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS = 1_000;
|
||||
const PLUGIN_RPC_DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const PLUGIN_RPC_MAX_PENDING = 256;
|
||||
const PLUGIN_RPC_MAX_JSON_BYTES = 1024 * 1024;
|
||||
// Broker payloads live inside the control-plane JSON envelope. Reserve enough
|
||||
// room for worst-case JSON string escaping (six wire bytes per raw byte),
|
||||
// headers, paths, correlation fields, and future compatible metadata.
|
||||
const PLUGIN_RPC_MAX_RAW_BYTES = Math.floor(PLUGIN_RPC_MAX_JSON_BYTES / 8);
|
||||
const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = 24 * 1024 * 1024;
|
||||
const PLUGIN_CRASH_WINDOW_MS = 5 * 60_000;
|
||||
const PLUGIN_CRASH_QUARANTINE_THRESHOLD = 3;
|
||||
const PLUGIN_LOG_MAX_BYTES = 1024 * 1024;
|
||||
const PLUGIN_LOG_MAX_FILES = 3;
|
||||
|
||||
function isPluginDevelopmentEnabled(env = process.env) {
|
||||
return env?.[PLUGIN_DEV_ENV] === "1";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLUGIN_ACTIVATION_TIMEOUT_MS,
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_CRASH_QUARANTINE_THRESHOLD,
|
||||
PLUGIN_CRASH_WINDOW_MS,
|
||||
PLUGIN_DEACTIVATION_TIMEOUT_MS,
|
||||
PLUGIN_DEV_ENV,
|
||||
PLUGIN_LOG_MAX_BYTES,
|
||||
PLUGIN_LOG_MAX_FILES,
|
||||
PLUGIN_PROTOCOL_SCHEME,
|
||||
PLUGIN_RPC_DEFAULT_TIMEOUT_MS,
|
||||
PLUGIN_RPC_MAX_RAW_BYTES,
|
||||
PLUGIN_RPC_MAX_JSON_BYTES,
|
||||
PLUGIN_RPC_MAX_PENDING,
|
||||
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS,
|
||||
PLUGIN_UTILITY_TERMINATION_GRACE_MS,
|
||||
isPluginDevelopmentEnabled,
|
||||
};
|
||||
166
electron/plugins/contextKeys.cjs
Normal file
166
electron/plugins/contextKeys.cjs
Normal file
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
|
||||
const MAX_EXPRESSION_LENGTH = 2_048;
|
||||
const MAX_TOKENS = 256;
|
||||
const IDENTIFIER = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,255}/u;
|
||||
const FULL_IDENTIFIER = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,255}$/u;
|
||||
const PLUGIN_ID = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u;
|
||||
const PLUGIN_CONTEXT_KEY_SUFFIX = /^[A-Za-z0-9_][A-Za-z0-9_:-]{0,255}$/u;
|
||||
|
||||
class ContextKeySyntaxError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ContextKeySyntaxError";
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(expression) {
|
||||
if (typeof expression !== "string" || expression.length < 1 || expression.length > MAX_EXPRESSION_LENGTH) {
|
||||
throw new ContextKeySyntaxError("Context Key expression length is invalid");
|
||||
}
|
||||
const tokens = [];
|
||||
let offset = 0;
|
||||
const push = (type, value = type) => {
|
||||
if (tokens.length >= MAX_TOKENS) throw new ContextKeySyntaxError("Context Key expression is too complex");
|
||||
tokens.push({ type, value });
|
||||
};
|
||||
while (offset < expression.length) {
|
||||
const rest = expression.slice(offset);
|
||||
const whitespace = /^\s+/u.exec(rest);
|
||||
if (whitespace) { offset += whitespace[0].length; continue; }
|
||||
const operator = /^(===|!==|&&|\|\||==|!=|>=|<=|>|<|!|\(|\))/u.exec(rest);
|
||||
if (operator) { push(operator[0]); offset += operator[0].length; continue; }
|
||||
const quoted = /^("(?:[^"\\]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4})*"|'(?:[^'\\]|\\['\\bfnrt]|\\u[0-9a-fA-F]{4})*')/u.exec(rest);
|
||||
if (quoted) {
|
||||
const source = quoted[0];
|
||||
let value;
|
||||
try {
|
||||
value = source[0] === "\"" ? JSON.parse(source) : JSON.parse(`"${source.slice(1, -1).replace(/\\'/gu, "'").replace(/"/gu, '\\"')}"`);
|
||||
} catch { throw new ContextKeySyntaxError("Context Key string is invalid"); }
|
||||
push("literal", value);
|
||||
offset += source.length;
|
||||
continue;
|
||||
}
|
||||
const identifier = IDENTIFIER.exec(rest);
|
||||
const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?/u.exec(rest);
|
||||
if (number && (!identifier || number[0].length >= identifier[0].length)) {
|
||||
push("literal", Number(number[0]));
|
||||
offset += number[0].length;
|
||||
continue;
|
||||
}
|
||||
if (!identifier) throw new ContextKeySyntaxError(`Unexpected Context Key token at ${offset}`);
|
||||
offset += identifier[0].length;
|
||||
if (identifier[0] === "true") push("literal", true);
|
||||
else if (identifier[0] === "false") push("literal", false);
|
||||
else if (identifier[0] === "null") push("literal", null);
|
||||
else if (identifier[0] === "in") push("in");
|
||||
else if (identifier[0] === "not") push("not");
|
||||
else push("identifier", identifier[0]);
|
||||
}
|
||||
push("eof");
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseContextKeyExpression(expression) {
|
||||
const tokens = tokenize(expression);
|
||||
let index = 0;
|
||||
const current = () => tokens[index];
|
||||
const consume = (type) => {
|
||||
if (current().type !== type) throw new ContextKeySyntaxError(`Expected ${type}`);
|
||||
return tokens[index++];
|
||||
};
|
||||
const primary = () => {
|
||||
if (current().type === "literal") return { type: "literal", value: consume("literal").value };
|
||||
if (current().type === "identifier") return { type: "key", name: consume("identifier").value };
|
||||
if (current().type === "(") {
|
||||
consume("(");
|
||||
const value = or();
|
||||
consume(")");
|
||||
return value;
|
||||
}
|
||||
throw new ContextKeySyntaxError("Expected Context Key value");
|
||||
};
|
||||
const unary = () => current().type === "!"
|
||||
? (consume("!"), { type: "not", value: unary() })
|
||||
: primary();
|
||||
const comparison = () => {
|
||||
const left = unary();
|
||||
let operator = current().type;
|
||||
if (operator === "not" && tokens[index + 1]?.type === "in") {
|
||||
consume("not"); consume("in"); operator = "not in";
|
||||
} else if (["==", "===", "!=", "!==", ">", ">=", "<", "<=", "in"].includes(operator)) {
|
||||
consume(operator);
|
||||
} else return left;
|
||||
return { type: "compare", operator, left, right: unary() };
|
||||
};
|
||||
const and = () => {
|
||||
let value = comparison();
|
||||
while (current().type === "&&") { consume("&&"); value = { type: "and", left: value, right: comparison() }; }
|
||||
return value;
|
||||
};
|
||||
const or = () => {
|
||||
let value = and();
|
||||
while (current().type === "||") { consume("||"); value = { type: "or", left: value, right: and() }; }
|
||||
return value;
|
||||
};
|
||||
const syntax = or();
|
||||
consume("eof");
|
||||
return Object.freeze(syntax);
|
||||
}
|
||||
|
||||
function contextValue(context, key) {
|
||||
if (context instanceof Map) return context.get(key);
|
||||
if (!context || typeof context !== "object") return undefined;
|
||||
return Object.hasOwn(context, key) ? context[key] : undefined;
|
||||
}
|
||||
|
||||
function evaluateSyntax(node, context) {
|
||||
if (node.type === "literal") return node.value;
|
||||
if (node.type === "key") return contextValue(context, node.name);
|
||||
if (node.type === "not") return !Boolean(evaluateSyntax(node.value, context));
|
||||
if (node.type === "and") return Boolean(evaluateSyntax(node.left, context)) && Boolean(evaluateSyntax(node.right, context));
|
||||
if (node.type === "or") return Boolean(evaluateSyntax(node.left, context)) || Boolean(evaluateSyntax(node.right, context));
|
||||
const left = evaluateSyntax(node.left, context);
|
||||
const right = evaluateSyntax(node.right, context);
|
||||
switch (node.operator) {
|
||||
case "==": case "===": return left === right;
|
||||
case "!=": case "!==": return left !== right;
|
||||
case ">": return typeof left === typeof right && left > right;
|
||||
case ">=": return typeof left === typeof right && left >= right;
|
||||
case "<": return typeof left === typeof right && left < right;
|
||||
case "<=": return typeof left === typeof right && left <= right;
|
||||
case "in": return Array.isArray(right) ? right.includes(left) : Boolean(right && typeof right === "object" && Object.hasOwn(right, String(left)));
|
||||
case "not in": return !(Array.isArray(right) ? right.includes(left) : Boolean(right && typeof right === "object" && Object.hasOwn(right, String(left))));
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateContextKeyExpression(expression, context) {
|
||||
if (expression == null || expression === "") return true;
|
||||
try { return Boolean(evaluateSyntax(parseContextKeyExpression(expression), context)); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function assertPluginContextKey(pluginId, key) {
|
||||
if (typeof pluginId !== "string" || pluginId.length > 128 || !PLUGIN_ID.test(pluginId)) {
|
||||
throw new TypeError("Plugin ID is invalid");
|
||||
}
|
||||
const prefix = `${pluginId}.`;
|
||||
const suffix = typeof key === "string" && key.startsWith(prefix)
|
||||
? key.slice(prefix.length)
|
||||
: "";
|
||||
if (typeof key !== "string" || !FULL_IDENTIFIER.test(key) || !PLUGIN_CONTEXT_KEY_SUFFIX.test(suffix)) {
|
||||
throw new TypeError(`Plugin Context Key must be namespaced to ${pluginId}`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ContextKeySyntaxError,
|
||||
MAX_EXPRESSION_LENGTH,
|
||||
MAX_TOKENS,
|
||||
assertPluginContextKey,
|
||||
evaluateContextKeyExpression,
|
||||
parseContextKeyExpression,
|
||||
tokenize,
|
||||
};
|
||||
46
electron/plugins/contextKeys.test.cjs
Normal file
46
electron/plugins/contextKeys.test.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
assertPluginContextKey,
|
||||
evaluateContextKeyExpression,
|
||||
parseContextKeyExpression,
|
||||
} = require("./contextKeys.cjs");
|
||||
|
||||
test("Context Keys evaluate a bounded host-owned grammar", () => {
|
||||
const context = {
|
||||
"terminal.connected": true,
|
||||
"terminal.kind": "ssh",
|
||||
"com.example.mode": "advanced",
|
||||
allowed: ["ssh", "telnet"],
|
||||
};
|
||||
assert.equal(evaluateContextKeyExpression(
|
||||
"terminal.connected && (terminal.kind == 'ssh' || com.example.mode == 'safe')",
|
||||
context,
|
||||
), true);
|
||||
assert.equal(evaluateContextKeyExpression("terminal.kind in allowed", context), true);
|
||||
assert.equal(evaluateContextKeyExpression("terminal.kind not in allowed", context), false);
|
||||
});
|
||||
|
||||
test("Context Keys fail closed for invalid or over-complex expressions", () => {
|
||||
assert.equal(evaluateContextKeyExpression("globalThis.constructor", {}), false);
|
||||
assert.equal(evaluateContextKeyExpression("key &&", { key: true }), false);
|
||||
assert.throws(() => parseContextKeyExpression("x || ".repeat(300) + "x"), /length|complex/u);
|
||||
});
|
||||
|
||||
test("plugin-created Context Keys must use the owning namespace", () => {
|
||||
assert.equal(assertPluginContextKey("com.example.plugin", "com.example.plugin.ready"), "com.example.plugin.ready");
|
||||
assert.equal(assertPluginContextKey("1.example.plugin", "1.example.plugin.ready"), "1.example.plugin.ready");
|
||||
assert.equal(assertPluginContextKey("com.example", "com.example.ready"), "com.example.ready");
|
||||
assert.equal(evaluateContextKeyExpression("1.example.plugin.ready", {
|
||||
"1.example.plugin.ready": true,
|
||||
}), true);
|
||||
assert.equal(evaluateContextKeyExpression("1.5 < 2", {}), true);
|
||||
assert.throws(() => assertPluginContextKey("com.example.plugin", "terminal.connected"), /namespaced/u);
|
||||
assert.throws(
|
||||
() => assertPluginContextKey("com.example", "com.example.plugin.ready"),
|
||||
/namespaced/u,
|
||||
);
|
||||
});
|
||||
112
electron/plugins/contractValidator.cjs
Normal file
112
electron/plugins/contractValidator.cjs
Normal file
@@ -0,0 +1,112 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const Ajv2020 = require("ajv/dist/2020").default;
|
||||
const addFormats = require("ajv-formats").default;
|
||||
const schema = require("./generated/plugin-contract.schema.json");
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const {
|
||||
PLUGIN_RPC_MAX_JSON_BYTES,
|
||||
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
} = require("./constants.cjs");
|
||||
|
||||
function createDefinitionValidator(definitionName) {
|
||||
if (!schema.$defs?.[definitionName]) throw new Error(`Unknown plugin contract definition: ${definitionName}`);
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
|
||||
addFormats(ajv);
|
||||
ajv.addSchema(schema);
|
||||
return ajv.compile({ $ref: `${schema.$id}#/$defs/${definitionName}` });
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors) {
|
||||
return (errors ?? [])
|
||||
.slice(0, 8)
|
||||
.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
const rpcMessageValidator = createDefinitionValidator("RpcMessage");
|
||||
const initializeResultValidator = createDefinitionValidator("RuntimeInitializeResult");
|
||||
const terminalInterceptorAttachmentParamsValidator = createDefinitionValidator("TerminalInterceptorAttachmentParams");
|
||||
const terminalInterceptorAttachmentResultValidator = createDefinitionValidator("TerminalInterceptorAttachmentResult");
|
||||
const terminalInterceptorFrameValidator = createDefinitionValidator("TerminalInterceptorFrame");
|
||||
const providerRequestValidator = createDefinitionValidator("ProviderRequest");
|
||||
const providerResultValidator = createDefinitionValidator("ProviderResult");
|
||||
const streamFrameValidator = createDefinitionValidator("StreamFrame");
|
||||
|
||||
function assertContractValue(validator, value, label) {
|
||||
if (!validator(value)) {
|
||||
throw new TypeError(`${label} violates the plugin contract: ${formatValidationErrors(validator.errors)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertRpcMessage(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(rpcMessageValidator, value, "RPC message");
|
||||
}
|
||||
|
||||
function assertInitializeResult(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(initializeResultValidator, value, "Initialize result");
|
||||
}
|
||||
|
||||
function assertTerminalInterceptorAttachmentParams(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(
|
||||
terminalInterceptorAttachmentParamsValidator,
|
||||
value,
|
||||
"Terminal interceptor attachment params",
|
||||
);
|
||||
}
|
||||
|
||||
function assertTerminalInterceptorAttachmentResult(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(
|
||||
terminalInterceptorAttachmentResultValidator,
|
||||
value,
|
||||
"Terminal interceptor attachment result",
|
||||
);
|
||||
}
|
||||
|
||||
function assertTerminalInterceptorFrameSchema(value) {
|
||||
return assertContractValue(
|
||||
terminalInterceptorFrameValidator,
|
||||
value,
|
||||
"Terminal interceptor frame",
|
||||
);
|
||||
}
|
||||
|
||||
function assertProviderRequest(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(providerRequestValidator, value, "Provider request");
|
||||
}
|
||||
|
||||
function assertProviderResult(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_RPC_MAX_JSON_BYTES });
|
||||
return assertContractValue(providerResultValidator, value, "Provider result");
|
||||
}
|
||||
|
||||
function assertStreamFrameSchema(value) {
|
||||
assertPluginJsonValue(value, { maxBytes: PLUGIN_STREAM_MAX_FRAME_JSON_BYTES });
|
||||
return assertContractValue(streamFrameValidator, value, "Stream frame");
|
||||
}
|
||||
|
||||
function resolveContractRuntimePath(...segments) {
|
||||
return path.join(__dirname, "..", "..", "node_modules", "@netcatty", "plugin-contract", "dist", ...segments);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertInitializeResult,
|
||||
assertTerminalInterceptorAttachmentParams,
|
||||
assertTerminalInterceptorAttachmentResult,
|
||||
assertTerminalInterceptorFrameSchema,
|
||||
assertProviderRequest,
|
||||
assertProviderResult,
|
||||
assertRpcMessage,
|
||||
assertStreamFrameSchema,
|
||||
createDefinitionValidator,
|
||||
formatValidationErrors,
|
||||
resolveContractRuntimePath,
|
||||
};
|
||||
115
electron/plugins/contributionIconRasterizer.cjs
Normal file
115
electron/plugins/contributionIconRasterizer.cjs
Normal file
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const DEFAULT_RASTER_TIMEOUT_MS = 5_000;
|
||||
|
||||
function withTimeout(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function browserRasterizeExpression(payload) {
|
||||
return `(async (payload) => {
|
||||
const image = new Image();
|
||||
image.decoding = "sync";
|
||||
if (payload.mimeType === "image/svg+xml") {
|
||||
const bytes = Uint8Array.from(atob(payload.source), (character) => character.charCodeAt(0));
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
const document = new DOMParser().parseFromString(source, "image/svg+xml");
|
||||
const root = document.documentElement;
|
||||
if (root.localName !== "svg" || document.querySelector("parsererror")) {
|
||||
throw new Error("Plugin contribution SVG icon is invalid");
|
||||
}
|
||||
root.setAttribute("width", String(payload.width));
|
||||
root.setAttribute("height", String(payload.height));
|
||||
const normalized = new XMLSerializer().serializeToString(root);
|
||||
image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(normalized);
|
||||
} else {
|
||||
image.src = "data:" + payload.mimeType + ";base64," + payload.source;
|
||||
}
|
||||
await image.decode();
|
||||
if (image.naturalWidth !== payload.width || image.naturalHeight !== payload.height) {
|
||||
throw new Error("Plugin contribution icon dimensions changed during decoding");
|
||||
}
|
||||
const scale = Math.min(1, payload.maxEdge / image.naturalWidth, payload.maxEdge / image.naturalHeight);
|
||||
const width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
const height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { alpha: true, desynchronized: false });
|
||||
if (!context) throw new Error("Plugin contribution icon canvas is unavailable");
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return canvas.toDataURL("image/png");
|
||||
})(${JSON.stringify(payload)})`;
|
||||
}
|
||||
|
||||
function createIsolatedContributionIconRasterizer(options) {
|
||||
const BrowserWindow = options?.BrowserWindow;
|
||||
const randomUUIDFn = options?.randomUUID ?? randomUUID;
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_RASTER_TIMEOUT_MS;
|
||||
return async ({ body, mimeType, width, height, maxEdge }) => {
|
||||
if (typeof BrowserWindow !== "function") {
|
||||
throw new Error("Isolated plugin contribution icon rasterizer is unavailable");
|
||||
}
|
||||
const partition = `plugin-icon-raster-${randomUUIDFn()}`;
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: maxEdge,
|
||||
height: maxEdge,
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
win.removeMenu?.();
|
||||
win.webContents?.session?.webRequest?.onBeforeRequest?.(
|
||||
{ urls: ["http://*/*", "https://*/*", "file://*/*", "ftp://*/*"] },
|
||||
(_details, callback) => callback({ cancel: true }),
|
||||
);
|
||||
await withTimeout(
|
||||
Promise.resolve(win.loadURL("data:text/html;charset=utf-8,%3Cmeta%20http-equiv%3D%22Content-Security-Policy%22%20content%3D%22default-src%20'none'%3B%20img-src%20data%3A%3B%22%3E")),
|
||||
timeoutMs,
|
||||
"Plugin contribution icon rasterizer did not load in time",
|
||||
);
|
||||
const result = await withTimeout(
|
||||
Promise.resolve(win.webContents.executeJavaScript(browserRasterizeExpression({
|
||||
source: body.toString("base64"),
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
maxEdge,
|
||||
}), true)),
|
||||
timeoutMs,
|
||||
"Plugin contribution icon rasterization timed out",
|
||||
);
|
||||
const prefix = "data:image/png;base64,";
|
||||
if (typeof result !== "string" || !result.startsWith(prefix)) {
|
||||
throw new Error("Plugin contribution icon rasterizer returned an invalid result");
|
||||
}
|
||||
return Buffer.from(result.slice(prefix.length), "base64");
|
||||
} finally {
|
||||
try { win.destroy(); } catch {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RASTER_TIMEOUT_MS,
|
||||
browserRasterizeExpression,
|
||||
createIsolatedContributionIconRasterizer,
|
||||
};
|
||||
98
electron/plugins/contributionIconRasterizer.test.cjs
Normal file
98
electron/plugins/contributionIconRasterizer.test.cjs
Normal file
@@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
browserRasterizeExpression,
|
||||
createIsolatedContributionIconRasterizer,
|
||||
} = require("./contributionIconRasterizer.cjs");
|
||||
|
||||
test("viewBox-only SVG icons receive an explicit isolated raster viewport", () => {
|
||||
const expression = browserRasterizeExpression({
|
||||
source: Buffer.from('<svg viewBox="0 0 32 16"><path d="M0 0h1"/></svg>').toString("base64"),
|
||||
mimeType: "image/svg+xml",
|
||||
width: 32,
|
||||
height: 16,
|
||||
maxEdge: 64,
|
||||
});
|
||||
assert.match(expression, /DOMParser/u);
|
||||
assert.match(expression, /setAttribute\("width"/u);
|
||||
assert.match(expression, /setAttribute\("height"/u);
|
||||
});
|
||||
|
||||
test("contribution icons rasterize in a disposable sandboxed network-denied window", async () => {
|
||||
let createdOptions;
|
||||
let destroyed = false;
|
||||
let requestFilter;
|
||||
let expression = "";
|
||||
class BrowserWindowStub {
|
||||
constructor(options) {
|
||||
createdOptions = options;
|
||||
this.webContents = {
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeRequest(filter, listener) { requestFilter = { filter, listener }; },
|
||||
},
|
||||
},
|
||||
async executeJavaScript(nextExpression) {
|
||||
expression = nextExpression;
|
||||
return `data:image/png;base64,${Buffer.from("bounded-png").toString("base64")}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
async loadURL(url) { assert.match(url, /^data:text\/html/); }
|
||||
removeMenu() {}
|
||||
destroy() { destroyed = true; }
|
||||
}
|
||||
const rasterize = createIsolatedContributionIconRasterizer({
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
randomUUID: () => "fixed",
|
||||
});
|
||||
|
||||
assert.deepEqual(await rasterize({
|
||||
body: Buffer.from("untrusted"),
|
||||
mimeType: "image/png",
|
||||
width: 32,
|
||||
height: 16,
|
||||
maxEdge: 64,
|
||||
}), Buffer.from("bounded-png"));
|
||||
assert.equal(createdOptions.show, false);
|
||||
assert.equal(createdOptions.webPreferences.partition, "plugin-icon-raster-fixed");
|
||||
assert.equal(createdOptions.webPreferences.sandbox, true);
|
||||
assert.equal(createdOptions.webPreferences.nodeIntegration, false);
|
||||
assert.equal(createdOptions.webPreferences.contextIsolation, true);
|
||||
assert.equal(createdOptions.webPreferences.spellcheck, false);
|
||||
assert.deepEqual(requestFilter.filter.urls, ["http://*/*", "https://*/*", "file://*/*", "ftp://*/*"]);
|
||||
let blocked;
|
||||
requestFilter.listener({}, (decision) => { blocked = decision; });
|
||||
assert.deepEqual(blocked, { cancel: true });
|
||||
assert.match(expression, /dW50cnVzdGVk/);
|
||||
assert.equal(destroyed, true);
|
||||
});
|
||||
|
||||
test("timed-out contribution icon rasterizers always destroy their isolated window", async () => {
|
||||
let destroyed = false;
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
this.webContents = {
|
||||
session: { webRequest: { onBeforeRequest() {} } },
|
||||
executeJavaScript: () => new Promise(() => {}),
|
||||
};
|
||||
}
|
||||
async loadURL() {}
|
||||
destroy() { destroyed = true; }
|
||||
}
|
||||
const rasterize = createIsolatedContributionIconRasterizer({
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
await assert.rejects(rasterize({
|
||||
body: Buffer.from("untrusted"),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
maxEdge: 64,
|
||||
}), /timed out/);
|
||||
assert.equal(destroyed, true);
|
||||
});
|
||||
260
electron/plugins/contributionIconService.cjs
Normal file
260
electron/plugins/contributionIconService.cjs
Normal file
@@ -0,0 +1,260 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const { readContainedFile } = require("./pluginProtocol.cjs");
|
||||
const { createIsolatedContributionIconRasterizer } = require("./contributionIconRasterizer.cjs");
|
||||
|
||||
const MAX_ICON_SOURCE_BYTES = 512 * 1024;
|
||||
const MAX_ICON_PNG_BYTES = 512 * 1024;
|
||||
const MAX_ICON_EDGE = 64;
|
||||
const MAX_ICON_SOURCE_EDGE = 4_096;
|
||||
const MAX_ICON_SOURCE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_CONCURRENT_ICON_RASTERIZATIONS = 2;
|
||||
const MAX_QUEUED_ICON_RASTERIZATIONS = 64;
|
||||
const CONTRIBUTION_KINDS = Object.freeze(["commands", "menus", "views"]);
|
||||
const EXTENSION_MIME_TYPES = Object.freeze({
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
});
|
||||
|
||||
function createBoundedIconRasterizer(rasterize, options = {}) {
|
||||
if (typeof rasterize !== "function") throw new TypeError("Plugin contribution icon rasterizer is unavailable");
|
||||
const maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_ICON_RASTERIZATIONS;
|
||||
const maxQueued = options.maxQueued ?? MAX_QUEUED_ICON_RASTERIZATIONS;
|
||||
if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1
|
||||
|| !Number.isSafeInteger(maxQueued) || maxQueued < 0) {
|
||||
throw new TypeError("Plugin contribution icon rasterization limits are invalid");
|
||||
}
|
||||
let active = 0;
|
||||
const queue = [];
|
||||
const drain = () => {
|
||||
while (active < maxConcurrent && queue.length > 0) {
|
||||
const task = queue.shift();
|
||||
active += 1;
|
||||
Promise.resolve().then(() => rasterize(task.payload)).then(task.resolve, task.reject).finally(() => {
|
||||
active -= 1;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
};
|
||||
return (payload) => new Promise((resolve, reject) => {
|
||||
if (queue.length >= maxQueued && active >= maxConcurrent) {
|
||||
reject(new Error("Plugin contribution icon rasterization queue is full"));
|
||||
return;
|
||||
}
|
||||
queue.push({ payload, resolve, reject });
|
||||
drain();
|
||||
});
|
||||
}
|
||||
|
||||
function inspectJpegDimensions(body) {
|
||||
if (body.length < 4 || body[0] !== 0xff || body[1] !== 0xd8) return null;
|
||||
const startOfFrame = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
||||
let offset = 2;
|
||||
while (offset + 3 < body.length) {
|
||||
while (offset < body.length && body[offset] === 0xff) offset += 1;
|
||||
const marker = body[offset++];
|
||||
if (marker === 0xd8 || marker === 0xd9) continue;
|
||||
if (marker === 0xda) break;
|
||||
if (offset + 2 > body.length) break;
|
||||
const length = body.readUInt16BE(offset);
|
||||
if (length < 2 || offset + length > body.length) break;
|
||||
if (startOfFrame.has(marker) && length >= 7) {
|
||||
return { height: body.readUInt16BE(offset + 3), width: body.readUInt16BE(offset + 5) };
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inspectSvgDimensions(body) {
|
||||
const source = body.toString("utf8");
|
||||
if (source.includes("\0") || /<\s*(?:script|foreignObject)\b/i.test(source)) return null;
|
||||
const match = source.match(/<svg\b([^>]*)>/i);
|
||||
if (!match || match[1].length > 8_192) return null;
|
||||
const attributes = match[1];
|
||||
const readLength = (name) => {
|
||||
const value = attributes.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"))?.[1]?.trim();
|
||||
if (!value || !/^(?:\d+(?:\.\d+)?|\.\d+)(?:px)?$/i.test(value)) return null;
|
||||
const numeric = Number.parseFloat(value);
|
||||
return Number.isFinite(numeric) ? Math.round(numeric) : null;
|
||||
};
|
||||
let width = readLength("width");
|
||||
let height = readLength("height");
|
||||
if (!width || !height) {
|
||||
const viewBox = attributes.match(/\bviewBox\s*=\s*["']([^"']+)["']/i)?.[1]
|
||||
?.trim().split(/[\s,]+/).map(Number);
|
||||
if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) {
|
||||
width = Math.round(Math.abs(viewBox[2]));
|
||||
height = Math.round(Math.abs(viewBox[3]));
|
||||
}
|
||||
}
|
||||
return width && height ? { width, height } : null;
|
||||
}
|
||||
|
||||
function inspectContributionIconSource(body, extension, maxEdge = MAX_ICON_SOURCE_EDGE) {
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) throw new Error("Plugin contribution icon is empty");
|
||||
const expectedMimeType = EXTENSION_MIME_TYPES[extension];
|
||||
if (!expectedMimeType) throw new Error("Plugin contribution icon format is unsupported");
|
||||
let mimeType = null;
|
||||
let dimensions = null;
|
||||
if (body.length >= 24
|
||||
&& body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
&& body.toString("ascii", 12, 16) === "IHDR") {
|
||||
mimeType = "image/png";
|
||||
dimensions = { width: body.readUInt32BE(16), height: body.readUInt32BE(20) };
|
||||
} else if (body.length >= 10 && /^GIF8[79]a$/.test(body.toString("ascii", 0, 6))) {
|
||||
mimeType = "image/gif";
|
||||
dimensions = { width: body.readUInt16LE(6), height: body.readUInt16LE(8) };
|
||||
} else if (body.length >= 22 && body.readUInt16LE(0) === 0 && body.readUInt16LE(2) === 1) {
|
||||
const count = body.readUInt16LE(4);
|
||||
if (count < 1 || body.length < 6 + (count * 16)) throw new Error("Plugin contribution icon header is invalid");
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = 6 + (index * 16);
|
||||
width = Math.max(width, body[offset] || 256);
|
||||
height = Math.max(height, body[offset + 1] || 256);
|
||||
}
|
||||
mimeType = "image/x-icon";
|
||||
dimensions = { width, height };
|
||||
} else if (body.length >= 30
|
||||
&& body.toString("ascii", 0, 4) === "RIFF"
|
||||
&& body.toString("ascii", 8, 12) === "WEBP") {
|
||||
const kind = body.toString("ascii", 12, 16);
|
||||
mimeType = "image/webp";
|
||||
if (kind === "VP8X" && body.length >= 30) {
|
||||
dimensions = {
|
||||
width: 1 + body.readUIntLE(24, 3),
|
||||
height: 1 + body.readUIntLE(27, 3),
|
||||
};
|
||||
} else if (kind === "VP8L" && body.length >= 25 && body[20] === 0x2f) {
|
||||
const bits = body.readUInt32LE(21);
|
||||
dimensions = { width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) };
|
||||
} else if (kind === "VP8 " && body.length >= 30
|
||||
&& body[23] === 0x9d && body[24] === 0x01 && body[25] === 0x2a) {
|
||||
dimensions = { width: body.readUInt16LE(26) & 0x3fff, height: body.readUInt16LE(28) & 0x3fff };
|
||||
}
|
||||
} else {
|
||||
const jpeg = inspectJpegDimensions(body);
|
||||
if (jpeg) {
|
||||
mimeType = "image/jpeg";
|
||||
dimensions = jpeg;
|
||||
} else if (extension === ".svg") {
|
||||
const svg = inspectSvgDimensions(body);
|
||||
if (svg) {
|
||||
mimeType = "image/svg+xml";
|
||||
dimensions = svg;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mimeType || !dimensions) throw new Error("Plugin contribution icon header is invalid");
|
||||
if (mimeType !== expectedMimeType) throw new Error("Plugin contribution icon format does not match its extension");
|
||||
const { width, height } = dimensions;
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
|
||||
throw new Error("Plugin contribution icon dimensions are invalid");
|
||||
}
|
||||
if (width > maxEdge || height > maxEdge || width * height > MAX_ICON_SOURCE_PIXELS) {
|
||||
throw new Error("Plugin contribution icon dimensions are too large");
|
||||
}
|
||||
return Object.freeze({ mimeType, width, height });
|
||||
}
|
||||
|
||||
function isDeclaredPackageIcon(manifest, icon) {
|
||||
if (!icon || icon.kind !== "package") return false;
|
||||
return CONTRIBUTION_KINDS.some((kind) => (
|
||||
(manifest?.contributes?.[kind] ?? []).some((contribution) => (
|
||||
contribution?.icon?.kind === "package"
|
||||
&& contribution.icon.light === icon.light
|
||||
&& contribution.icon.dark === icon.dark
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
class PluginContributionIconService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.packageStore = options.packageStore;
|
||||
const rasterizeIcon = options.rasterizeIcon ?? createIsolatedContributionIconRasterizer({
|
||||
BrowserWindow: options.BrowserWindow,
|
||||
});
|
||||
this.rasterizeIcon = createBoundedIconRasterizer(rasterizeIcon, {
|
||||
maxConcurrent: options.maxConcurrentRasterizations,
|
||||
maxQueued: options.maxQueuedRasterizations,
|
||||
});
|
||||
this.cache = new Map();
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
async resolve(payload) {
|
||||
const pluginId = typeof payload?.pluginId === "string" && payload.pluginId.length <= 256
|
||||
? payload.pluginId
|
||||
: null;
|
||||
const icon = payload?.icon;
|
||||
const plugin = pluginId ? this.database.getActivePlugin(pluginId) : null;
|
||||
if (!pluginId || !isDeclaredPackageIcon(plugin?.manifest, icon)) {
|
||||
throw new TypeError("Plugin contribution icon is not declared by the active plugin");
|
||||
}
|
||||
if (!plugin?.enabled || plugin.runtime?.quarantinedAt != null || !plugin.activeVersion) {
|
||||
throw new Error("Plugin contribution icon is unavailable");
|
||||
}
|
||||
const cacheKey = JSON.stringify([pluginId, plugin.activeVersion, icon.light, icon.dark ?? null]);
|
||||
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
|
||||
if (this.pending.has(cacheKey)) return this.pending.get(cacheKey);
|
||||
const pending = this.#resolveUncached({ pluginId, plugin, icon, cacheKey }).finally(() => {
|
||||
if (this.pending.get(cacheKey) === pending) this.pending.delete(cacheKey);
|
||||
});
|
||||
this.pending.set(cacheKey, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async #resolveUncached({ pluginId, plugin, icon, cacheKey }) {
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
const result = Object.freeze({
|
||||
light: await this.#readIcon(packageRoot, icon.light),
|
||||
...(icon.dark ? { dark: await this.#readIcon(packageRoot, icon.dark) } : {}),
|
||||
});
|
||||
const current = this.database.getActivePlugin(pluginId);
|
||||
if (!current?.enabled || current.runtime?.quarantinedAt != null
|
||||
|| current.activeVersion !== plugin.activeVersion
|
||||
|| !isDeclaredPackageIcon(current.manifest, icon)) {
|
||||
throw new Error("Plugin contribution icon ownership changed while loading");
|
||||
}
|
||||
if (this.cache.size >= 256) this.cache.clear();
|
||||
this.cache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async #readIcon(packageRoot, packagePath) {
|
||||
const segments = packagePath.split("/");
|
||||
const file = await readContainedFile(packageRoot, segments);
|
||||
if (file.body.byteLength > MAX_ICON_SOURCE_BYTES) throw new Error("Plugin contribution icon is too large");
|
||||
const extension = path.extname(file.filePath).toLowerCase();
|
||||
const inspected = inspectContributionIconSource(file.body, extension);
|
||||
const png = await this.rasterizeIcon({
|
||||
body: file.body,
|
||||
...inspected,
|
||||
maxEdge: MAX_ICON_EDGE,
|
||||
});
|
||||
if (!png.length || png.length > MAX_ICON_PNG_BYTES) throw new Error("Plugin contribution icon output is too large");
|
||||
inspectContributionIconSource(png, ".png", MAX_ICON_EDGE);
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_ICON_EDGE,
|
||||
MAX_CONCURRENT_ICON_RASTERIZATIONS,
|
||||
MAX_QUEUED_ICON_RASTERIZATIONS,
|
||||
MAX_ICON_SOURCE_EDGE,
|
||||
PluginContributionIconService,
|
||||
createBoundedIconRasterizer,
|
||||
inspectContributionIconSource,
|
||||
isDeclaredPackageIcon,
|
||||
};
|
||||
151
electron/plugins/contributionIconService.test.cjs
Normal file
151
electron/plugins/contributionIconService.test.cjs
Normal file
@@ -0,0 +1,151 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PluginContributionIconService,
|
||||
createBoundedIconRasterizer,
|
||||
inspectContributionIconSource,
|
||||
} = require("./contributionIconService.cjs");
|
||||
|
||||
function pngHeader(width, height) {
|
||||
const body = Buffer.alloc(24);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(body);
|
||||
body.writeUInt32BE(13, 8);
|
||||
body.write("IHDR", 12, "ascii");
|
||||
body.writeUInt32BE(width, 16);
|
||||
body.writeUInt32BE(height, 20);
|
||||
return body;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-icon-"));
|
||||
fs.mkdirSync(path.join(root, "assets"));
|
||||
fs.writeFileSync(path.join(root, "assets", "light.png"), pngHeader(128, 64));
|
||||
fs.writeFileSync(path.join(root, "assets", "dark.png"), pngHeader(128, 64));
|
||||
const icon = { kind: "package", light: "assets/light.png", dark: "assets/dark.png" };
|
||||
const plugin = {
|
||||
id: "com.example.icon",
|
||||
activeVersion: "1.0.0",
|
||||
enabled: true,
|
||||
runtime: { quarantinedAt: null },
|
||||
manifest: { contributes: { views: [{ id: "com.example.icon.view", icon }] } },
|
||||
};
|
||||
const rasterized = [];
|
||||
const service = new PluginContributionIconService({
|
||||
database: { getActivePlugin: (id) => id === plugin.id ? plugin : null },
|
||||
packageStore: { preparePackageRoot: async () => root },
|
||||
async rasterizeIcon(input) {
|
||||
rasterized.push({ mimeType: input.mimeType, width: input.width, height: input.height, maxEdge: input.maxEdge });
|
||||
return pngHeader(64, 32);
|
||||
},
|
||||
});
|
||||
return { icon, plugin, rasterized, root, service };
|
||||
}
|
||||
|
||||
test("package contribution icons are declaration-bound, integrity-prepared, and rasterized", async (t) => {
|
||||
const { icon, plugin, rasterized, root, service } = fixture();
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
assert.deepEqual(await service.resolve({ pluginId: plugin.id, icon }), {
|
||||
light: `data:image/png;base64,${pngHeader(64, 32).toString("base64")}`,
|
||||
dark: `data:image/png;base64,${pngHeader(64, 32).toString("base64")}`,
|
||||
});
|
||||
assert.deepEqual(rasterized, [
|
||||
{ mimeType: "image/png", width: 128, height: 64, maxEdge: 64 },
|
||||
{ mimeType: "image/png", width: 128, height: 64, maxEdge: 64 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("concurrent requests for one declared package icon share one isolated decode", async (t) => {
|
||||
const { icon, plugin, rasterized, root, service } = fixture();
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
service.resolve({ pluginId: plugin.id, icon }),
|
||||
service.resolve({ pluginId: plugin.id, icon }),
|
||||
]);
|
||||
assert.equal(first, second);
|
||||
assert.equal(rasterized.length, 2);
|
||||
});
|
||||
|
||||
test("isolated icon decoding has bounded concurrency and queue depth", async () => {
|
||||
let releaseFirst;
|
||||
const started = [];
|
||||
const rasterize = createBoundedIconRasterizer(async ({ id }) => {
|
||||
started.push(id);
|
||||
if (id === "first") await new Promise((resolve) => { releaseFirst = resolve; });
|
||||
return id;
|
||||
}, { maxConcurrent: 1, maxQueued: 1 });
|
||||
|
||||
const first = rasterize({ id: "first" });
|
||||
const second = rasterize({ id: "second" });
|
||||
await assert.rejects(rasterize({ id: "third" }), /queue is full/);
|
||||
assert.deepEqual(started, ["first"]);
|
||||
releaseFirst();
|
||||
assert.deepEqual(await Promise.all([first, second]), ["first", "second"]);
|
||||
assert.deepEqual(started, ["first", "second"]);
|
||||
});
|
||||
|
||||
test("package contribution icon lookup rejects paths not declared by the active manifest", async (t) => {
|
||||
const { plugin, root, service } = fixture();
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
await assert.rejects(service.resolve({
|
||||
pluginId: plugin.id,
|
||||
icon: { kind: "package", light: "assets/other.png" },
|
||||
}), /not declared/);
|
||||
});
|
||||
|
||||
test("package contribution icon lookup rejects version replacement during decoding", async (t) => {
|
||||
const { icon, plugin, root } = fixture();
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let current = plugin;
|
||||
const service = new PluginContributionIconService({
|
||||
database: { getActivePlugin: () => current },
|
||||
packageStore: {
|
||||
async preparePackageRoot() {
|
||||
current = { ...plugin, activeVersion: "2.0.0" };
|
||||
return root;
|
||||
},
|
||||
},
|
||||
rasterizeIcon: async () => pngHeader(16, 16),
|
||||
});
|
||||
|
||||
await assert.rejects(service.resolve({ pluginId: plugin.id, icon }), /ownership changed/);
|
||||
});
|
||||
|
||||
test("oversized or disguised package icons fail before isolated decoding", async (t) => {
|
||||
const oversized = fixture();
|
||||
t.after(() => fs.rmSync(oversized.root, { recursive: true, force: true }));
|
||||
fs.writeFileSync(path.join(oversized.root, "assets", "light.png"), pngHeader(16_384, 16_384));
|
||||
await assert.rejects(
|
||||
oversized.service.resolve({ pluginId: oversized.plugin.id, icon: oversized.icon }),
|
||||
/dimensions are too large/,
|
||||
);
|
||||
assert.deepEqual(oversized.rasterized, []);
|
||||
|
||||
const disguised = fixture();
|
||||
t.after(() => fs.rmSync(disguised.root, { recursive: true, force: true }));
|
||||
fs.writeFileSync(path.join(disguised.root, "assets", "light.png"), Buffer.from("GIF89a\u0001\u0000\u0001\u0000", "binary"));
|
||||
await assert.rejects(
|
||||
disguised.service.resolve({ pluginId: disguised.plugin.id, icon: disguised.icon }),
|
||||
/does not match its extension/,
|
||||
);
|
||||
assert.deepEqual(disguised.rasterized, []);
|
||||
});
|
||||
|
||||
test("SVG preflight requires bounded declarative dimensions and rejects active content", () => {
|
||||
assert.deepEqual(inspectContributionIconSource(
|
||||
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16"><path d="M0 0h1"/></svg>'),
|
||||
".svg",
|
||||
), { mimeType: "image/svg+xml", width: 32, height: 16 });
|
||||
assert.throws(() => inspectContributionIconSource(
|
||||
Buffer.from('<svg width="32" height="16"><script>alert(1)</script></svg>'),
|
||||
".svg",
|
||||
), /header is invalid/);
|
||||
});
|
||||
615
electron/plugins/contributionService.cjs
Normal file
615
electron/plugins/contributionService.cjs
Normal file
@@ -0,0 +1,615 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
|
||||
const { assertPluginContextKey, evaluateContextKeyExpression } = require("./contextKeys.cjs");
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_SETTING_BYTES = 128 * 1024;
|
||||
const MAX_PATTERN_LENGTH = 512;
|
||||
const MAX_PATTERN_VALUE_LENGTH = 4_096;
|
||||
const MAX_SCOPE_ID_LENGTH = 256;
|
||||
const SETTINGS_SCOPES = new Set(["application", "workspace", "host", "session", "device"]);
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
function notFound(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.notFound, message);
|
||||
}
|
||||
|
||||
function freezeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function assertJsonValue(value, label = "value") {
|
||||
try { assertPluginJsonValue(value, { maxBytes: MAX_SETTING_BYTES }); }
|
||||
catch (error) {
|
||||
throw invalidArgument(`Plugin ${label} must be a bounded JSON value: ${error?.message ?? error}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveLocalizedText(value, locale = "en") {
|
||||
if (typeof value === "string") return value;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
const base = locale.split("-")[0];
|
||||
return value[locale] ?? value[base] ?? value.en ?? value.default ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
function normalizeScopeId(scope, scopeId) {
|
||||
if (!SETTINGS_SCOPES.has(scope)) throw invalidArgument("Plugin setting scope is invalid");
|
||||
if (scope === "application") return "application";
|
||||
const value = scope === "device" && scopeId == null ? "device" : scopeId;
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > MAX_SCOPE_ID_LENGTH || value.includes("\0")) {
|
||||
throw invalidArgument(`Plugin ${scope} setting requires a valid scope ID`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getContribution(manifest, kind, id) {
|
||||
return manifest?.contributes?.[kind]?.find((item) => item.id === id || item.command === id) ?? null;
|
||||
}
|
||||
|
||||
function compileSettingPattern(source, settingId = "setting") {
|
||||
if (typeof source !== "string" || source.length < 1 || source.length > MAX_PATTERN_LENGTH
|
||||
|| /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source)
|
||||
|| /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) {
|
||||
throw invalidArgument(`${settingId} has an unsafe pattern`);
|
||||
}
|
||||
try { return new RegExp(source, "u"); }
|
||||
catch { throw invalidArgument(`${settingId} has an invalid pattern`); }
|
||||
}
|
||||
|
||||
function assertSettingValue(setting, value) {
|
||||
assertJsonValue(value, "setting value");
|
||||
const options = new Set((setting.options ?? []).map((option) => option.value));
|
||||
switch (setting.control) {
|
||||
case "switch":
|
||||
if (typeof value !== "boolean") throw invalidArgument(`${setting.id} must be a boolean`);
|
||||
break;
|
||||
case "radio": case "select":
|
||||
if (typeof value !== "string" || (options.size && !options.has(value))) {
|
||||
throw invalidArgument(`${setting.id} must use a declared option`);
|
||||
}
|
||||
break;
|
||||
case "multiselect":
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || (options.size && !options.has(item)))
|
||||
|| new Set(value).size !== value.length) {
|
||||
throw invalidArgument(`${setting.id} must be a unique list of declared options`);
|
||||
}
|
||||
break;
|
||||
case "number": case "slider":
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw invalidArgument(`${setting.id} must be finite`);
|
||||
if (setting.minimum != null && value < setting.minimum) throw invalidArgument(`${setting.id} is below its minimum`);
|
||||
if (setting.maximum != null && value > setting.maximum) throw invalidArgument(`${setting.id} is above its maximum`);
|
||||
if (setting.step != null) {
|
||||
const steps = (value - (setting.minimum ?? 0)) / setting.step;
|
||||
const tolerance = Number.EPSILON * Math.max(1, Math.abs(steps)) * 16;
|
||||
if (Math.abs(steps - Math.round(steps)) > tolerance) {
|
||||
throw invalidArgument(`${setting.id} does not align to its step`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list": case "table":
|
||||
if (!Array.isArray(value)) throw invalidArgument(`${setting.id} must be an array`);
|
||||
try { compileRestrictedJsonSchema(setting.valueSchema, { rootType: "array" })(value); }
|
||||
catch (error) { throw invalidArgument(`${setting.id} ${error?.message ?? error}`); }
|
||||
break;
|
||||
default:
|
||||
if (typeof value !== "string") throw invalidArgument(`${setting.id} must be a string`);
|
||||
if (setting.pattern) {
|
||||
if (value.length > MAX_PATTERN_VALUE_LENGTH) {
|
||||
throw invalidArgument(`${setting.id} exceeds the patterned text limit`);
|
||||
}
|
||||
const pattern = compileSettingPattern(setting.pattern, setting.id);
|
||||
if (!pattern.test(value)) throw invalidArgument(`${setting.id} does not match its pattern`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretSettingKey(settingId, scope, scopeId) {
|
||||
const digest = createHash("sha256").update(`${scope}\0${scopeId}`).digest("hex").slice(0, 32);
|
||||
return `setting:${settingId}:${digest}`;
|
||||
}
|
||||
|
||||
function activationEvents(manifest) {
|
||||
return new Set(manifest?.activationEvents ?? []);
|
||||
}
|
||||
|
||||
function shouldActivateOnStartup(manifest) {
|
||||
return activationEvents(manifest).has("onStartupFinished");
|
||||
}
|
||||
|
||||
function resolvePlatformKeybinding(keybinding, platform = process.platform) {
|
||||
if (!keybinding) return undefined;
|
||||
if (platform === "darwin") return keybinding.mac ?? keybinding.key;
|
||||
if (platform === "win32") return keybinding.windows ?? keybinding.key;
|
||||
if (platform === "linux") return keybinding.linux ?? keybinding.key;
|
||||
return keybinding.key;
|
||||
}
|
||||
|
||||
function isContributionAvailable(plugin) {
|
||||
return plugin?.enabled === true
|
||||
&& plugin.manifest != null
|
||||
&& plugin.runtime?.quarantinedAt == null;
|
||||
}
|
||||
|
||||
class PluginContributionService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.secretStore = options.secretStore;
|
||||
this.getLocale = options.getLocale ?? (() => "en");
|
||||
this.contextKeys = new Map();
|
||||
this.contextKeyOwners = new Map();
|
||||
this.listeners = new Set();
|
||||
this.viewMessageListeners = new Set();
|
||||
this.environment = null;
|
||||
this.environmentRevision = 0;
|
||||
this.runtimeEnvironmentState = new Map();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
registry.registerRequest("settings.get", (params, context) => this.#runtimeGetSetting(params, context), {
|
||||
metadata: { capability: "settings", mutating: false, permission: "settings.read" },
|
||||
authorization: { permission: "settings.read", resources: ["*"], reason: "Read plugin settings" },
|
||||
});
|
||||
registry.registerRequest("settings.update", (params, context) => this.#runtimeUpdateSetting(params, context), {
|
||||
metadata: { capability: "settings", mutating: true, permission: "settings.write" },
|
||||
authorization: { permission: "settings.write", resources: ["*"], reason: "Update plugin settings" },
|
||||
});
|
||||
registry.registerRequest("commands.execute", (params, context) => this.executeCommand(params?.command, params?.args, {
|
||||
source: "plugin",
|
||||
callerPluginId: context.pluginId,
|
||||
}), {
|
||||
metadata: { capability: "commands", mutating: true, permission: "commands" },
|
||||
authorization: { permission: "commands", resources: ["*"], reason: "Execute a plugin command" },
|
||||
});
|
||||
registry.registerRequest("contextKeys.set", (params, context) => {
|
||||
const key = assertPluginContextKey(context.pluginId, params?.key);
|
||||
assertJsonValue(params?.value, "Context Key value");
|
||||
const owner = this.contextKeyOwners.get(key);
|
||||
if (owner != null && owner !== context.pluginId) {
|
||||
throw invalidArgument("Plugin Context Key is owned by another plugin");
|
||||
}
|
||||
this.contextKeys.set(key, freezeJson(params.value));
|
||||
this.contextKeyOwners.set(key, context.pluginId);
|
||||
this.#emitChange("context-key", context.pluginId);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "contextKeys", mutating: true, public: true },
|
||||
});
|
||||
registry.registerRequest("views.getState", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
const scopeId = normalizeScopeId("session", params?.scopeId);
|
||||
return { state: this.database.getViewState(context.pluginId, view.id, scopeId) };
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: false, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Restore plugin view state" },
|
||||
});
|
||||
registry.registerRequest("views.setState", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
const scopeId = normalizeScopeId("session", params?.scopeId);
|
||||
assertJsonValue(params?.state, "view state");
|
||||
this.database.setViewState(context.pluginId, view.id, scopeId, params.state);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: true, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Persist plugin view state" },
|
||||
});
|
||||
registry.registerNotification("views.postMessage", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
assertJsonValue(params?.message, "view message");
|
||||
const event = freezeJson({ pluginId: context.pluginId, viewId: view.id, message: params.message });
|
||||
for (const listener of [...this.viewMessageListeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: true, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Message a plugin view" },
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.quarantinedAt != null || !shouldActivateOnStartup(plugin.manifest)) continue;
|
||||
try { await this.#startPlugin(plugin.id); } catch {}
|
||||
}
|
||||
this.#emitChange("initialized");
|
||||
}
|
||||
|
||||
onDidChange(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin contribution listener must be a function");
|
||||
this.listeners.add(listener);
|
||||
return Object.freeze({ dispose: () => this.listeners.delete(listener) });
|
||||
}
|
||||
|
||||
onDidPostViewMessage(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
this.viewMessageListeners.add(listener);
|
||||
return Object.freeze({ dispose: () => this.viewMessageListeners.delete(listener) });
|
||||
}
|
||||
|
||||
#emitChange(reason, pluginId = null) {
|
||||
const event = Object.freeze({ reason, pluginId, revision: Date.now() });
|
||||
for (const listener of [...this.listeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async onPluginEnabled(pluginId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.enabled) throw notFound(`Plugin is not enabled: ${pluginId}`);
|
||||
if (shouldActivateOnStartup(plugin.manifest)) await this.#startPlugin(pluginId);
|
||||
this.#emitChange("plugin-enabled", pluginId);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
onPluginDisabled(pluginId) {
|
||||
this.#clearRuntimeOwnedState(pluginId);
|
||||
this.#emitChange("plugin-disabled", pluginId);
|
||||
}
|
||||
|
||||
onRuntimeStateChanged(event) {
|
||||
if (!["stopped", "error", "quarantined"].includes(event?.status) || typeof event.pluginId !== "string") return;
|
||||
this.#clearRuntimeOwnedState(event.pluginId);
|
||||
this.#emitChange(`runtime-${event.status}`, event.pluginId);
|
||||
}
|
||||
|
||||
getEnvironment() {
|
||||
return this.environment;
|
||||
}
|
||||
|
||||
#clearRuntimeOwnedState(pluginId) {
|
||||
this.runtimeEnvironmentState.delete(pluginId);
|
||||
for (const [key, owner] of [...this.contextKeyOwners.entries()]) {
|
||||
if (owner !== pluginId) continue;
|
||||
this.contextKeyOwners.delete(key);
|
||||
this.contextKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#findContribution(kind, id) {
|
||||
if (typeof id !== "string") throw invalidArgument(`Plugin ${kind} ID is required`);
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
const contribution = getContribution(plugin.manifest, kind, id);
|
||||
if (contribution) return { plugin, contribution };
|
||||
}
|
||||
throw notFound(`Plugin ${kind} contribution was not found: ${id}`);
|
||||
}
|
||||
|
||||
#assertOwnedContribution(manifest, kind, id, pluginId) {
|
||||
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`)) throw invalidArgument(`Plugin ${kind} ID is invalid`);
|
||||
const contribution = getContribution(manifest, kind, id);
|
||||
if (!contribution) throw notFound(`Plugin ${kind} contribution was not found: ${id}`);
|
||||
return contribution;
|
||||
}
|
||||
|
||||
async #ensureActivated(plugin, event) {
|
||||
const events = activationEvents(plugin.manifest);
|
||||
const isContributionEvent = /^(?:onCommand|onView|onProvider):/u.test(event);
|
||||
if (!events.has(event) && !events.has("onStartupFinished") && !isContributionEvent) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin activation event is not declared: ${event}`);
|
||||
}
|
||||
// Declared UI contributions are implicit lazy activation points. This keeps
|
||||
// manifest activationEvents backward compatible while avoiding eager start.
|
||||
return this.#startPlugin(plugin.id);
|
||||
}
|
||||
|
||||
async #startPlugin(pluginId) {
|
||||
const identity = await this.runtimeSupervisor.start(pluginId);
|
||||
if (this.environment == null) return identity;
|
||||
const runtimeId = identity?.runtimeId ?? null;
|
||||
const seeded = this.runtimeEnvironmentState.get(pluginId);
|
||||
if (runtimeId != null && seeded?.runtimeId === runtimeId && seeded.revision === this.environmentRevision) {
|
||||
return identity;
|
||||
}
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.environment.changed", this.environment);
|
||||
if (runtimeId != null) {
|
||||
this.runtimeEnvironmentState.set(pluginId, { runtimeId, revision: this.environmentRevision });
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async executeCommand(commandId, args, invocation = {}) {
|
||||
const { plugin, contribution } = this.#findContribution("commands", commandId);
|
||||
if (invocation.callerPluginId && invocation.callerPluginId !== plugin.id) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugins may execute only their own declared commands");
|
||||
}
|
||||
const keys = this.#context(invocation.context);
|
||||
if (!evaluateContextKeyExpression(contribution.enablement, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin command is disabled: ${commandId}`);
|
||||
}
|
||||
assertJsonValue(args ?? null, "command arguments");
|
||||
await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
return this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
});
|
||||
}
|
||||
|
||||
async activateView(viewId, params = {}) {
|
||||
const { plugin, contribution } = this.#findContribution("views", viewId);
|
||||
const keys = this.#context(params.context);
|
||||
if (!evaluateContextKeyExpression(contribution.when, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin view is unavailable: ${viewId}`);
|
||||
}
|
||||
const identity = await this.#ensureActivated(plugin, `onView:${viewId}`);
|
||||
return { plugin, view: contribution, identity: identity ?? null };
|
||||
}
|
||||
|
||||
assertViewActivationCurrent(expected, params = {}) {
|
||||
if (!expected || typeof expected !== "object") throw invalidArgument("Plugin view activation identity is invalid");
|
||||
const { plugin, contribution } = this.#findContribution("views", expected.viewId);
|
||||
if (plugin.id !== expected.pluginId || plugin.activeVersion !== expected.pluginVersion) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin view ownership changed while opening");
|
||||
}
|
||||
const keys = this.#context(params.context);
|
||||
if (!evaluateContextKeyExpression(contribution.when, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin view is unavailable: ${expected.viewId}`);
|
||||
}
|
||||
const identity = this.runtimeSupervisor.getRuntimeIdentity?.(plugin.id) ?? null;
|
||||
if (!identity?.runtimeId || identity.runtimeId !== expected.runtimeId
|
||||
|| identity.pluginVersion !== expected.pluginVersion) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin view runtime ownership changed while opening");
|
||||
}
|
||||
return { plugin, view: contribution, identity };
|
||||
}
|
||||
|
||||
async activateProvider(providerId) {
|
||||
const { plugin, contribution } = this.#findContribution("providers", providerId);
|
||||
const identity = await this.#ensureActivated(plugin, `onProvider:${providerId}`);
|
||||
return Object.freeze({ plugin, provider: contribution, identity: identity ?? null });
|
||||
}
|
||||
|
||||
listProviders(options = {}) {
|
||||
if (options.kind != null && (typeof options.kind !== "string" || options.kind.length < 1)) {
|
||||
throw invalidArgument("Plugin Provider kind is invalid");
|
||||
}
|
||||
const locale = options.locale ?? this.getLocale();
|
||||
const providers = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
for (const provider of plugin.manifest.contributes?.providers ?? []) {
|
||||
if (options.kind != null && provider.kind !== options.kind) continue;
|
||||
providers.push({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
pluginDisplayName: resolveLocalizedText(
|
||||
plugin.manifest.displayName ?? plugin.manifest.name,
|
||||
locale,
|
||||
),
|
||||
provider: {
|
||||
...provider,
|
||||
label: resolveLocalizedText(provider.label, locale),
|
||||
description: provider.description == null
|
||||
? undefined
|
||||
: resolveLocalizedText(provider.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return freezeJson(providers);
|
||||
}
|
||||
|
||||
#settingRecord(pluginId, settingId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!isContributionAvailable(plugin)) throw notFound(`Plugin is not enabled: ${pluginId}`);
|
||||
const setting = this.#assertOwnedContribution(plugin.manifest, "settings", settingId, pluginId);
|
||||
return { plugin, setting };
|
||||
}
|
||||
|
||||
async getSetting(pluginId, settingId, scopeId) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
if (setting.secret) {
|
||||
return this.secretStore.getReference(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId)) ?? undefined;
|
||||
}
|
||||
const stored = this.database.getSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
|
||||
return stored === undefined ? freezeJson(setting.default) : stored;
|
||||
}
|
||||
|
||||
async updateSetting(pluginId, settingId, value, scopeId, options = {}) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
assertSettingValue(setting, value);
|
||||
if (setting.secret) {
|
||||
this.secretStore.set(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId), value);
|
||||
} else {
|
||||
this.database.setSetting(pluginId, setting.id, setting.scope, normalizedScopeId, value);
|
||||
}
|
||||
this.#emitChange("setting-updated", pluginId);
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
|
||||
settingId: setting.id,
|
||||
scope: setting.scope,
|
||||
scopeId: normalizedScopeId,
|
||||
source: options.source ?? "host",
|
||||
});
|
||||
} catch {}
|
||||
return { restartRequired: setting.restartRequired === true };
|
||||
}
|
||||
|
||||
async resetSetting(pluginId, settingId, scopeId) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
if (setting.required && setting.default === undefined) throw invalidArgument(`${setting.id} is required`);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
if (setting.secret) this.secretStore.delete(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId));
|
||||
else this.database.deleteSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
|
||||
// Clear the matching cloud-sync sidecar so collect does not rehydrate the
|
||||
// retained remote value after an intentional local reset.
|
||||
if (typeof this.database.deleteSyncSidecar === "function" && !setting.secret) {
|
||||
const sidecarKey = `${setting.id}\0${setting.scope}\0${normalizedScopeId}`;
|
||||
try {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", sidecarKey);
|
||||
} catch {}
|
||||
}
|
||||
this.#emitChange("setting-reset", pluginId);
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
|
||||
settingId: setting.id,
|
||||
scope: setting.scope,
|
||||
scopeId: normalizedScopeId,
|
||||
source: "host",
|
||||
});
|
||||
} catch {}
|
||||
return { restartRequired: setting.restartRequired === true };
|
||||
}
|
||||
|
||||
#runtimeGetSetting(params, context) {
|
||||
return this.getSetting(context.pluginId, params?.settingId, params?.scopeId).then((value) => ({
|
||||
found: value !== undefined,
|
||||
...(value === undefined ? {} : { value }),
|
||||
}));
|
||||
}
|
||||
|
||||
#runtimeUpdateSetting(params, context) {
|
||||
return this.updateSetting(context.pluginId, params?.settingId, params?.value, params?.scopeId, { source: "plugin" });
|
||||
}
|
||||
|
||||
#context(extra) {
|
||||
return Object.assign(Object.create(null), extra ?? {}, Object.fromEntries(this.contextKeys));
|
||||
}
|
||||
|
||||
snapshot(options = {}) {
|
||||
const locale = options.locale ?? this.getLocale();
|
||||
const context = this.#context(options.context);
|
||||
const plugins = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
const contributes = plugin.manifest.contributes ?? {};
|
||||
const localize = (value) => resolveLocalizedText(value, locale);
|
||||
const commands = (contributes.commands ?? []).map((command) => ({
|
||||
...command,
|
||||
title: localize(command.title),
|
||||
category: command.category == null ? undefined : localize(command.category),
|
||||
description: command.description == null ? undefined : localize(command.description),
|
||||
enabled: evaluateContextKeyExpression(command.enablement, context),
|
||||
}));
|
||||
const commandById = new Map(commands.map((command) => [command.id, command]));
|
||||
const keybindings = (contributes.keybindings ?? []).map((keybinding) => ({
|
||||
...keybinding,
|
||||
enabled: evaluateContextKeyExpression(keybinding.when, context)
|
||||
&& commandById.get(keybinding.command)?.enabled !== false,
|
||||
}));
|
||||
const keybindingByCommand = new Map();
|
||||
for (const keybinding of keybindings) {
|
||||
if (keybinding.enabled && !keybindingByCommand.has(keybinding.command)) {
|
||||
keybindingByCommand.set(keybinding.command, keybinding);
|
||||
}
|
||||
}
|
||||
const menus = (contributes.menus ?? []).map((menu, index) => {
|
||||
const menuContext = this.#context(options.menuContexts?.[menu.location] ?? options.context);
|
||||
const command = commandById.get(menu.command);
|
||||
return {
|
||||
...menu,
|
||||
id: `${plugin.id}:menu:${index}`,
|
||||
title: menu.title == null ? command?.title ?? menu.command : localize(menu.title),
|
||||
visible: evaluateContextKeyExpression(menu.when, menuContext),
|
||||
enabled: evaluateContextKeyExpression(menu.enablement, menuContext)
|
||||
&& evaluateContextKeyExpression(command?.enablement, menuContext),
|
||||
checked: menu.checked == null ? undefined : evaluateContextKeyExpression(menu.checked, menuContext),
|
||||
shortcut: menu.showKeybinding === false
|
||||
? undefined
|
||||
: resolvePlatformKeybinding(keybindingByCommand.get(menu.command), options.platform),
|
||||
};
|
||||
});
|
||||
const settings = (contributes.settings ?? []).map((setting) => {
|
||||
const requestedScopeId = options.scopeIds?.[setting.scope];
|
||||
const scopeId = setting.scope === "application"
|
||||
? "application"
|
||||
: setting.scope === "device"
|
||||
? (requestedScopeId ?? "device")
|
||||
: (requestedScopeId ?? null);
|
||||
const stored = scopeId == null
|
||||
? undefined
|
||||
: setting.secret
|
||||
? this.secretStore.getReference(plugin.id, secretSettingKey(setting.id, setting.scope, scopeId))
|
||||
: this.database.getSetting(plugin.id, setting.id, setting.scope, scopeId);
|
||||
return {
|
||||
...setting,
|
||||
label: localize(setting.label),
|
||||
description: setting.description == null ? undefined : localize(setting.description),
|
||||
placeholder: setting.placeholder == null ? undefined : localize(setting.placeholder),
|
||||
options: setting.options?.map((option) => ({
|
||||
...option,
|
||||
label: localize(option.label),
|
||||
description: option.description == null ? undefined : localize(option.description),
|
||||
})),
|
||||
visible: evaluateContextKeyExpression(setting.when, context),
|
||||
configured: stored !== undefined && stored !== null,
|
||||
value: setting.secret ? undefined : freezeJson(stored === undefined ? setting.default : stored),
|
||||
scopeId,
|
||||
};
|
||||
});
|
||||
const views = (contributes.views ?? []).map((view) => ({
|
||||
...view,
|
||||
title: localize(view.title),
|
||||
visible: evaluateContextKeyExpression(view.when, context),
|
||||
}));
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
version: plugin.activeVersion,
|
||||
displayName: localize(plugin.manifest.displayName ?? plugin.manifest.name),
|
||||
description: localize(plugin.manifest.description ?? ""),
|
||||
commands,
|
||||
keybindings,
|
||||
menus,
|
||||
settings,
|
||||
views,
|
||||
});
|
||||
}
|
||||
return freezeJson({ locale, plugins });
|
||||
}
|
||||
|
||||
async setEnvironment(environment) {
|
||||
assertJsonValue(environment, "environment");
|
||||
this.environment = freezeJson(environment);
|
||||
this.environmentRevision += 1;
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.status !== "running") continue;
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(plugin.id, "plugin.environment.changed", this.environment);
|
||||
const identity = this.runtimeSupervisor.getRuntimeIdentity?.(plugin.id);
|
||||
if (identity?.runtimeId) {
|
||||
this.runtimeEnvironmentState.set(plugin.id, {
|
||||
runtimeId: identity.runtimeId,
|
||||
revision: this.environmentRevision,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
this.#emitChange("environment");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SETTING_BYTES,
|
||||
MAX_PATTERN_VALUE_LENGTH,
|
||||
PluginContributionService,
|
||||
assertJsonValue,
|
||||
assertSettingValue,
|
||||
compileSettingPattern,
|
||||
normalizeScopeId,
|
||||
resolveLocalizedText,
|
||||
resolvePlatformKeybinding,
|
||||
secretSettingKey,
|
||||
isContributionAvailable,
|
||||
shouldActivateOnStartup,
|
||||
};
|
||||
683
electron/plugins/contributionService.test.cjs
Normal file
683
electron/plugins/contributionService.test.cjs
Normal file
@@ -0,0 +1,683 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { assertJsonValue, MAX_SETTING_BYTES, PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
|
||||
function manifest(id = "com.example.contributions", activationEvents = []) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
name: "contributions",
|
||||
displayName: { en: "Contributions", "zh-CN": "贡献" },
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "index.js" },
|
||||
activationEvents,
|
||||
contributes: {
|
||||
settings: [{
|
||||
id: `${id}.greeting`,
|
||||
label: { en: "Greeting", "zh-CN": "问候" },
|
||||
control: "text",
|
||||
scope: "application",
|
||||
default: "hello",
|
||||
}, {
|
||||
id: `${id}.token`,
|
||||
label: "Token",
|
||||
control: "password",
|
||||
scope: "application",
|
||||
secret: true,
|
||||
}],
|
||||
commands: [{ id: `${id}.hello`, title: { en: "Say hello", "zh-CN": "打招呼" }, enablement: `${id}.ready` }],
|
||||
menus: [{ command: `${id}.hello`, location: "commandPalette", when: `${id}.visible` }],
|
||||
keybindings: [{ command: `${id}.hello`, key: "ctrl+shift+h", when: `${id}.visible` }],
|
||||
views: [{ id: `${id}.view`, title: "Example view", location: "aside", entry: "view.html" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installPlugin(database, pluginManifest) {
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${pluginManifest.id}/${pluginManifest.version}/package`,
|
||||
}, { enable: true });
|
||||
}
|
||||
|
||||
function setup(context, pluginManifest, options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-contributions-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
context.after(() => database.close());
|
||||
installPlugin(database, pluginManifest);
|
||||
const secrets = new Map();
|
||||
const secretStore = {
|
||||
getReference(pluginId, key) { return secrets.has(`${pluginId}:${key}`) ? secrets.get(`${pluginId}:${key}`).ref : null; },
|
||||
set(pluginId, key, value) {
|
||||
const ref = { kind: "secret", id: `secret-${"x".repeat(24)}`, key };
|
||||
secrets.set(`${pluginId}:${key}`, { ref, value });
|
||||
return ref;
|
||||
},
|
||||
delete(pluginId, key) { secrets.delete(`${pluginId}:${key}`); },
|
||||
};
|
||||
const calls = [];
|
||||
const runtimeSupervisor = options.runtimeSupervisor ?? {
|
||||
async start(pluginId) {
|
||||
calls.push(`start:${pluginId}`);
|
||||
return { runtimeId: `runtime:${pluginId}`, pluginVersion: pluginManifest.version };
|
||||
},
|
||||
async request(pluginId, method, params) { calls.push(`request:${pluginId}:${method}`); return params; },
|
||||
async notify(pluginId, method) { calls.push(`notify:${pluginId}:${method}`); },
|
||||
getRuntimeIdentity(pluginId) {
|
||||
return { runtimeId: `runtime:${pluginId}`, pluginVersion: pluginManifest.version };
|
||||
},
|
||||
};
|
||||
const service = new PluginContributionService({
|
||||
database,
|
||||
runtimeSupervisor,
|
||||
secretStore,
|
||||
getLocale: () => "zh-CN",
|
||||
});
|
||||
return { calls, database, secretStore, service, secrets };
|
||||
}
|
||||
|
||||
test("plugin contribution values enforce the exact JSON value boundary", () => {
|
||||
assert.deepEqual(assertJsonValue({ nested: [null, true, 1, "ok"] }), {
|
||||
nested: [null, true, 1, "ok"],
|
||||
});
|
||||
for (const value of [new Date(), new Map(), Number.NaN, Number.POSITIVE_INFINITY, undefined]) {
|
||||
assert.throws(() => assertJsonValue(value), /bounded JSON value/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("only onStartupFinished plugins activate during contribution initialization", async (context) => {
|
||||
const pluginManifest = manifest("com.example.startup", ["onStartupFinished"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
await service.initialize();
|
||||
assert.deepEqual(calls, ["start:com.example.startup"]);
|
||||
|
||||
const lazyManifest = manifest("com.example.lazy", ["onCommand:com.example.lazy.hello"]);
|
||||
const lazy = setup(context, lazyManifest);
|
||||
await lazy.service.initialize();
|
||||
assert.deepEqual(lazy.calls, []);
|
||||
});
|
||||
|
||||
test("commands activate lazily and Context Keys are evaluated by the host", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].displayName, "贡献");
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].visible, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].checked, undefined);
|
||||
assert.equal(snapshot.plugins[0].menus[0].shortcut, "ctrl+shift+h");
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, true);
|
||||
|
||||
const result = await service.executeCommand(`${pluginManifest.id}.hello`, { name: "Catty" });
|
||||
assert.equal(result.command, `${pluginManifest.id}.hello`);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
`start:${pluginManifest.id}`,
|
||||
`request:${pluginManifest.id}:plugin.command.execute`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("Context Keys remain available without an unrelated menus permission", async (context) => {
|
||||
const pluginManifest = manifest("com.example.command-only", ["onCommand:com.example.command-only.hello"]);
|
||||
pluginManifest.permissions = { required: ["commands"] };
|
||||
pluginManifest.contributes.settings = [];
|
||||
pluginManifest.contributes.menus = [];
|
||||
pluginManifest.contributes.views = [];
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(new PluginPermissionEngine({ database }).createMiddleware());
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-command-only",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal });
|
||||
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, true);
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["contextKeys.set"]({
|
||||
key: "com.example.other.ready",
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal }),
|
||||
/must be namespaced to/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("Context Key ownership stays exact for plugin IDs that share a prefix", async (context) => {
|
||||
const shorter = manifest("com.example");
|
||||
const longer = manifest("com.example.foo");
|
||||
const { database, service } = setup(context, shorter);
|
||||
installPlugin(database, longer);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const transport = { signal: new AbortController().signal };
|
||||
const longerRoutes = registry.createRoutes({
|
||||
pluginId: longer.id,
|
||||
pluginVersion: longer.version,
|
||||
runtimeId: "runtime-longer",
|
||||
runtimeKind: "browser",
|
||||
manifest: longer,
|
||||
});
|
||||
const shorterRoutes = registry.createRoutes({
|
||||
pluginId: shorter.id,
|
||||
pluginVersion: shorter.version,
|
||||
runtimeId: "runtime-shorter",
|
||||
runtimeKind: "browser",
|
||||
manifest: shorter,
|
||||
});
|
||||
|
||||
await longerRoutes.requestHandlers["contextKeys.set"]({
|
||||
key: `${longer.id}.ready`,
|
||||
value: true,
|
||||
}, transport);
|
||||
await assert.rejects(
|
||||
shorterRoutes.requestHandlers["contextKeys.set"]({
|
||||
key: `${longer.id}.ready`,
|
||||
value: false,
|
||||
}, transport),
|
||||
/must be namespaced to/u,
|
||||
);
|
||||
|
||||
service.onRuntimeStateChanged({ pluginId: shorter.id, status: "stopped" });
|
||||
const longerSnapshot = service.snapshot().plugins.find((plugin) => plugin.id === longer.id);
|
||||
assert.equal(longerSnapshot.commands[0].enabled, true);
|
||||
});
|
||||
|
||||
test("secret settings enforce patterns and size bounds before safeStorage", async (context) => {
|
||||
const pluginManifest = manifest("com.example.secret-constraints");
|
||||
const secretSetting = pluginManifest.contributes.settings.find((setting) => setting.secret);
|
||||
secretSetting.pattern = "^[A-Z]{4}$";
|
||||
const { secrets, service } = setup(context, pluginManifest);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, secretSetting.id, "ABCD");
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, secretSetting.id, "invalid"),
|
||||
/does not match its pattern/u,
|
||||
);
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
|
||||
delete secretSetting.pattern;
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, secretSetting.id, "x".repeat(MAX_SETTING_BYTES)),
|
||||
/bounded JSON value/u,
|
||||
);
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
});
|
||||
|
||||
test("host invocation context cannot override plugin-owned Context Keys", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: false,
|
||||
}, { signal: new AbortController().signal });
|
||||
|
||||
await assert.rejects(service.executeCommand(`${pluginManifest.id}.hello`, undefined, {
|
||||
context: { [`${pluginManifest.id}.ready`]: true },
|
||||
}), /command is disabled/u);
|
||||
});
|
||||
|
||||
test("quarantined plugins are excluded from snapshots and contribution lookup", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
|
||||
assert.deepEqual(service.snapshot().plugins, []);
|
||||
await assert.rejects(
|
||||
service.executeCommand(`${pluginManifest.id}.hello`),
|
||||
/contribution was not found/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`),
|
||||
/Plugin is not enabled/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime quarantine clears owned Context Keys and publishes contribution removal", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
const changes = [];
|
||||
service.onDidChange((event) => changes.push(event));
|
||||
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
service.onRuntimeStateChanged({
|
||||
pluginId: pluginManifest.id,
|
||||
status: "quarantined",
|
||||
});
|
||||
|
||||
assert.equal(changes.at(-1).reason, "runtime-quarantined");
|
||||
assert.equal(changes.at(-1).pluginId, pluginManifest.id);
|
||||
assert.deepEqual(service.snapshot().plugins, []);
|
||||
database.clearQuarantine(pluginManifest.id, pluginManifest.version);
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, false);
|
||||
assert.equal(snapshot.plugins[0].menus[0].visible, false);
|
||||
});
|
||||
|
||||
test("every terminal runtime state clears runtime-owned Context Keys", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-terminal",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const changes = [];
|
||||
service.onDidChange((event) => changes.push(event));
|
||||
for (const status of ["stopped", "error"]) {
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal });
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, true);
|
||||
|
||||
service.onRuntimeStateChanged({ pluginId: pluginManifest.id, status });
|
||||
|
||||
assert.equal(changes.at(-1).reason, `runtime-${status}`);
|
||||
assert.equal(changes.at(-1).pluginId, pluginManifest.id);
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("menu and keybinding enablement include the target command state", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.menus[0].enablement = `${pluginManifest.id}.paletteEnabled`;
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
|
||||
let snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].enabled, false);
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, true);
|
||||
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: false }, transport);
|
||||
snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, false);
|
||||
});
|
||||
|
||||
test("menu shortcut display uses the current platform and honors explicit suppression", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.keybindings[0].mac = "cmd+shift+h";
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
assert.equal(service.snapshot({ platform: "darwin" }).plugins[0].menus[0].shortcut, undefined);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
|
||||
const visible = service.snapshot({ platform: "darwin" });
|
||||
assert.equal(visible.plugins[0].menus[0].shortcut, "cmd+shift+h");
|
||||
|
||||
const suppressedManifest = manifest("com.example.shortcut-suppressed");
|
||||
delete suppressedManifest.contributes.commands[0].enablement;
|
||||
delete suppressedManifest.contributes.keybindings[0].when;
|
||||
suppressedManifest.contributes.menus[0].showKeybinding = false;
|
||||
const suppressed = setup(context, suppressedManifest);
|
||||
assert.equal(suppressed.service.snapshot({ platform: "darwin" }).plugins[0].menus[0].shortcut, undefined);
|
||||
});
|
||||
|
||||
test("surface-specific view conditions are evaluated from the requesting host context", (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.views[0].when = "netcatty.surface == 'settings'";
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
assert.equal(service.snapshot().plugins[0].views[0].visible, false);
|
||||
assert.equal(service.snapshot({
|
||||
context: { "netcatty.surface": "commandPalette" },
|
||||
}).plugins[0].views[0].visible, false);
|
||||
assert.equal(service.snapshot({
|
||||
context: { "netcatty.surface": "settings" },
|
||||
}).plugins[0].views[0].visible, true);
|
||||
});
|
||||
|
||||
test("menu placements use their own source-specific host contexts", (context) => {
|
||||
const pluginManifest = manifest("com.example.menu-contexts");
|
||||
pluginManifest.contributes.commands = [{
|
||||
id: `${pluginManifest.id}.toolbar`,
|
||||
title: "Toolbar command",
|
||||
enablement: "netcatty.surface == 'terminal/toolbar' && terminal.sessionId == 'session-1'",
|
||||
}, {
|
||||
id: `${pluginManifest.id}.status`,
|
||||
title: "Status command",
|
||||
enablement: "netcatty.surface == 'statusBar' && terminal.sessionId == 'session-1'",
|
||||
}];
|
||||
pluginManifest.contributes.menus = [{
|
||||
command: `${pluginManifest.id}.toolbar`,
|
||||
location: "terminal/toolbar",
|
||||
when: "netcatty.surface == 'terminal/toolbar'",
|
||||
}, {
|
||||
command: `${pluginManifest.id}.status`,
|
||||
location: "statusBar",
|
||||
when: "netcatty.surface == 'statusBar'",
|
||||
}];
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const toolbarContext = { "netcatty.surface": "terminal/toolbar", "terminal.sessionId": "session-1" };
|
||||
const statusBarContext = { "netcatty.surface": "statusBar", "terminal.sessionId": "session-1" };
|
||||
|
||||
const snapshot = service.snapshot({
|
||||
context: toolbarContext,
|
||||
menuContexts: {
|
||||
"terminal/toolbar": toolbarContext,
|
||||
statusBar: statusBarContext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot.plugins[0].menus.map((menu) => ({
|
||||
command: menu.command,
|
||||
visible: menu.visible,
|
||||
enabled: menu.enabled,
|
||||
})), [{
|
||||
command: `${pluginManifest.id}.toolbar`,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}, {
|
||||
command: `${pluginManifest.id}.status`,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("lazy activation receives the latest host environment before its first contribution call", async (context) => {
|
||||
const pluginManifest = manifest("com.example.environment", ["onView:com.example.environment.view"]);
|
||||
const calls = [];
|
||||
const runtimeSupervisor = {
|
||||
async start(pluginId) {
|
||||
calls.push(["start", pluginId]);
|
||||
return { runtimeId: "runtime-environment-1" };
|
||||
},
|
||||
async notify(pluginId, method, params) {
|
||||
calls.push(["notify", pluginId, method, params]);
|
||||
},
|
||||
async request() { return null; },
|
||||
getRuntimeIdentity() { return { runtimeId: "runtime-environment-1", pluginVersion: pluginManifest.version }; },
|
||||
};
|
||||
const { service } = setup(context, pluginManifest, { runtimeSupervisor });
|
||||
const environment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: false };
|
||||
await service.setEnvironment(environment);
|
||||
assert.deepEqual(service.getEnvironment(), environment);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
await service.activateView(`${pluginManifest.id}.view`);
|
||||
assert.deepEqual(calls, [
|
||||
["start", pluginManifest.id],
|
||||
["notify", pluginManifest.id, "plugin.environment.changed", environment],
|
||||
]);
|
||||
});
|
||||
|
||||
test("view activation identity fails closed after version or runtime replacement", async (context) => {
|
||||
const pluginManifest = manifest("com.example.view-identity", ["onView:com.example.view-identity.view"]);
|
||||
let runtimeId = "runtime-view-1";
|
||||
const runtimeSupervisor = {
|
||||
async start() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
async request() { return null; },
|
||||
async notify() {},
|
||||
getRuntimeIdentity() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
};
|
||||
const { database, service } = setup(context, pluginManifest, { runtimeSupervisor });
|
||||
const activated = await service.activateView(`${pluginManifest.id}.view`);
|
||||
const expected = {
|
||||
pluginId: activated.plugin.id,
|
||||
pluginVersion: activated.plugin.activeVersion,
|
||||
viewId: activated.view.id,
|
||||
runtimeId: activated.identity.runtimeId,
|
||||
};
|
||||
assert.equal(service.assertViewActivationCurrent(expected).view.id, activated.view.id);
|
||||
|
||||
runtimeId = "runtime-view-2";
|
||||
assert.throws(() => service.assertViewActivationCurrent(expected), /runtime ownership changed/u);
|
||||
|
||||
runtimeId = "runtime-view-1";
|
||||
database.setEnabled(pluginManifest.id, false);
|
||||
assert.throws(() => service.assertViewActivationCurrent(expected), /contribution was not found/u);
|
||||
});
|
||||
|
||||
test("settings validate declared controls and secrets never enter settings storage", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, secrets, service } = setup(context, pluginManifest);
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "hello");
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "bonjour");
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "bonjour");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, 42),
|
||||
/must be a string/u,
|
||||
);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.token`, "plaintext");
|
||||
const reference = await service.getSetting(pluginManifest.id, `${pluginManifest.id}.token`);
|
||||
assert.equal(reference.kind, "secret");
|
||||
assert.equal(database.listSettings(pluginManifest.id).some((entry) => entry.settingId.endsWith(".token")), false);
|
||||
assert.equal([...secrets.values()][0].value, "plaintext");
|
||||
await service.resetSetting(pluginManifest.id, `${pluginManifest.id}.greeting`);
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "hello");
|
||||
});
|
||||
|
||||
test("numeric setting writes enforce the declared range and step", async (context) => {
|
||||
const pluginManifest = manifest("com.example.numeric-setting");
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.opacity`,
|
||||
label: "Opacity",
|
||||
control: "slider",
|
||||
scope: "application",
|
||||
minimum: 0.1,
|
||||
maximum: 1,
|
||||
step: 0.1,
|
||||
default: 0.5,
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 0.3);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 0.35),
|
||||
/align to its step/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 1.1),
|
||||
/above its maximum/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("contextual settings expose values only for an explicit host-owned scope", async (context) => {
|
||||
const pluginManifest = manifest("com.example.scoped-settings");
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.workspace-label`,
|
||||
label: "Workspace label",
|
||||
control: "text",
|
||||
scope: "workspace",
|
||||
default: "default-label",
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(
|
||||
pluginManifest.id,
|
||||
`${pluginManifest.id}.workspace-label`,
|
||||
"production",
|
||||
"workspace-1",
|
||||
);
|
||||
|
||||
const withoutScope = service.snapshot().plugins[0].settings
|
||||
.find((setting) => setting.id.endsWith(".workspace-label"));
|
||||
assert.equal(withoutScope.scopeId, null);
|
||||
assert.equal(withoutScope.value, "default-label");
|
||||
|
||||
const withScope = service.snapshot({ scopeIds: { workspace: "workspace-1" } }).plugins[0].settings
|
||||
.find((setting) => setting.id.endsWith(".workspace-label"));
|
||||
assert.equal(withScope.scopeId, "workspace-1");
|
||||
assert.equal(withScope.value, "production");
|
||||
});
|
||||
|
||||
test("user settings and view state survive package removal", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "preserved");
|
||||
database.setViewState(pluginManifest.id, `${pluginManifest.id}.view`, "window-1", { selected: 2 });
|
||||
database.removePlugin(pluginManifest.id);
|
||||
assert.equal(database.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "application", "application"), "preserved");
|
||||
assert.deepEqual(database.getViewState(pluginManifest.id, `${pluginManifest.id}.view`, "window-1"), { selected: 2 });
|
||||
});
|
||||
|
||||
test("structured settings use the host restricted schema and unsafe text patterns fail closed", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.servers`,
|
||||
label: "Servers",
|
||||
control: "table",
|
||||
scope: "application",
|
||||
valueSchema: {
|
||||
type: "array",
|
||||
maxItems: 4,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 32 },
|
||||
port: { type: "integer", minimum: 1, maximum: 65_535 },
|
||||
},
|
||||
required: ["name", "port"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
id: `${pluginManifest.id}.patterned`,
|
||||
label: "Patterned",
|
||||
control: "text",
|
||||
scope: "application",
|
||||
pattern: "^[a-z]+$",
|
||||
}, {
|
||||
id: `${pluginManifest.id}.unsafe-pattern`,
|
||||
label: "Unsafe pattern",
|
||||
control: "text",
|
||||
scope: "application",
|
||||
pattern: "^(a+)+$",
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.servers`, [{ name: "ssh", port: 22 }]);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.servers`, [{ name: "ssh", port: 70_000 }]),
|
||||
/does not match/u,
|
||||
);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.patterned`, "netcatty");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.unsafe-pattern`, "aaaa"),
|
||||
/unsafe pattern/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("Provider contributions expose a PR 5 lazy activation seam", async (context) => {
|
||||
const pluginManifest = manifest("com.example.provider", ["onProvider:com.example.provider.completion"]);
|
||||
pluginManifest.contributes.providers = [{
|
||||
id: "com.example.provider.completion",
|
||||
label: { en: "Completion", "zh-CN": "补全" },
|
||||
description: { en: "Complete input", "zh-CN": "补全输入" },
|
||||
kind: "terminal.completion",
|
||||
}, {
|
||||
id: "com.example.provider.connection",
|
||||
label: "Connection",
|
||||
kind: "connection",
|
||||
}];
|
||||
const calls = [];
|
||||
const identity = { pluginId: pluginManifest.id, runtimeId: "runtime-provider-1" };
|
||||
const { service } = setup(context, pluginManifest, {
|
||||
runtimeSupervisor: {
|
||||
async start(pluginId) { calls.push(`start:${pluginId}`); return identity; },
|
||||
async notify() {},
|
||||
async request() { return null; },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(service.listProviders({ kind: "terminal.completion" }), [{
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
pluginDisplayName: "贡献",
|
||||
provider: {
|
||||
id: "com.example.provider.completion",
|
||||
label: "补全",
|
||||
description: "补全输入",
|
||||
kind: "terminal.completion",
|
||||
},
|
||||
}]);
|
||||
const result = await service.activateProvider("com.example.provider.completion");
|
||||
assert.equal(result.provider.id, "com.example.provider.completion");
|
||||
assert.deepEqual(result.identity, identity);
|
||||
assert.deepEqual(calls, ["start:com.example.provider"]);
|
||||
});
|
||||
129
electron/plugins/credentialBroker.cjs
Normal file
129
electron/plugins/credentialBroker.cjs
Normal file
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { MAX_SECRET_BYTES, assertSecretRef } = require("./secretStore.cjs");
|
||||
|
||||
function assertCredentialRef(credential) {
|
||||
if (credential?.kind === "secret") {
|
||||
return { kind: "secret", ...assertSecretRef(credential) };
|
||||
}
|
||||
if (
|
||||
!credential
|
||||
|| typeof credential !== "object"
|
||||
|| Array.isArray(credential)
|
||||
|| credential.kind !== "credential"
|
||||
|| typeof credential.id !== "string"
|
||||
|| credential.id.length < 16
|
||||
|| credential.id.length > 256
|
||||
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Credential reference is invalid");
|
||||
return { kind: "credential", id: credential.id };
|
||||
}
|
||||
|
||||
function assertLeaseParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Credential lease parameters are invalid");
|
||||
}
|
||||
const credential = assertCredentialRef(params.secret);
|
||||
if (typeof params.operationId !== "string" || params.operationId.length < 1 || params.operationId.length > 128) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Credential lease operation ID is invalid");
|
||||
}
|
||||
if (typeof params.purpose !== "string" || params.purpose.length < 1 || params.purpose.length > 256) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Credential lease purpose is invalid");
|
||||
}
|
||||
if (
|
||||
params.ttlMs !== undefined
|
||||
&& (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1 || params.ttlMs > 60_000)
|
||||
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Credential lease lifetime is invalid");
|
||||
return {
|
||||
secret: credential,
|
||||
operationId: params.operationId,
|
||||
purpose: params.purpose,
|
||||
...(params.ttlMs === undefined ? {} : { ttlMs: params.ttlMs }),
|
||||
};
|
||||
}
|
||||
|
||||
class PluginCredentialBroker {
|
||||
constructor(options) {
|
||||
this.secretStore = options.secretStore;
|
||||
this.leaseStore = options.leaseStore;
|
||||
this.credentialResolver = options.credentialResolver ?? null;
|
||||
if (this.credentialResolver && (
|
||||
typeof this.credentialResolver.assertReference !== "function"
|
||||
|| typeof this.credentialResolver.resolve !== "function"
|
||||
)) throw new TypeError("Credential resolver must validate and resolve credential references");
|
||||
}
|
||||
|
||||
describeAuthorization(params) {
|
||||
const validated = assertLeaseParams(params);
|
||||
if (validated.secret.kind === "credential") {
|
||||
return {
|
||||
permission: "vault.credentials",
|
||||
resources: [`credential:${validated.secret.id}`],
|
||||
reason: `Use Netcatty credential for ${validated.purpose}`,
|
||||
operationId: validated.operationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
permission: "secrets",
|
||||
resources: [`secret:${validated.secret.key}`],
|
||||
reason: `Use plugin secret for ${validated.purpose}`,
|
||||
operationId: validated.operationId,
|
||||
};
|
||||
}
|
||||
|
||||
async createLease(params, context) {
|
||||
const validated = assertLeaseParams(params);
|
||||
await context.assertActive();
|
||||
let resolveSecret;
|
||||
if (validated.secret.kind === "credential") {
|
||||
if (!this.credentialResolver) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Netcatty credential access is unavailable");
|
||||
}
|
||||
await this.credentialResolver.assertReference(validated.secret, {
|
||||
pluginId: context.pluginId,
|
||||
runtimeId: context.runtimeId,
|
||||
operationId: validated.operationId,
|
||||
purpose: validated.purpose,
|
||||
signal: context.signal,
|
||||
});
|
||||
await context.assertActive();
|
||||
resolveSecret = async (consumeContext) => {
|
||||
const value = await this.credentialResolver.resolve(
|
||||
validated.secret,
|
||||
Object.freeze({ ...consumeContext, purpose: validated.purpose }),
|
||||
);
|
||||
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Resolved credential is invalid or too large");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else {
|
||||
this.secretStore.getRecordByReference(context.pluginId, validated.secret);
|
||||
}
|
||||
return this.leaseStore.issue({
|
||||
pluginId: context.pluginId,
|
||||
runtimeId: context.runtimeId,
|
||||
credential: validated.secret,
|
||||
operationId: validated.operationId,
|
||||
purpose: validated.purpose,
|
||||
ttlMs: validated.ttlMs,
|
||||
signal: context.signal,
|
||||
resolveSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async consumeLease(context, lease, operationId) {
|
||||
await context.assertActive();
|
||||
const value = await this.leaseStore.consume({
|
||||
pluginId: context.pluginId,
|
||||
runtimeId: context.runtimeId,
|
||||
lease,
|
||||
operationId,
|
||||
signal: context.signal,
|
||||
});
|
||||
await context.assertActive();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginCredentialBroker, assertCredentialRef, assertLeaseParams };
|
||||
103
electron/plugins/credentialCatalog.cjs
Normal file
103
electron/plugins/credentialCatalog.cjs
Normal file
@@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { MAX_SECRET_BYTES } = require("./secretStore.cjs");
|
||||
|
||||
const ENCRYPTED_CREDENTIAL_PREFIX = "enc:v1:";
|
||||
const MAX_CREDENTIAL_CATALOG_ENTRIES = 4_096;
|
||||
const MAX_CREDENTIAL_CIPHERTEXT_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function assertCredentialId(id) {
|
||||
if (typeof id !== "string" || id.length < 16 || id.length > 256 || id.includes("\0")) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential reference is invalid");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function decodeCiphertext(value) {
|
||||
if (typeof value !== "string" || !value.startsWith(ENCRYPTED_CREDENTIAL_PREFIX)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential must use OS-backed encryption");
|
||||
}
|
||||
const encoded = value.slice(ENCRYPTED_CREDENTIAL_PREFIX.length);
|
||||
if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential ciphertext is invalid");
|
||||
}
|
||||
const ciphertext = Buffer.from(encoded, "base64");
|
||||
if (ciphertext.byteLength < 1 || ciphertext.byteLength > MAX_CREDENTIAL_CIPHERTEXT_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential ciphertext is invalid or too large");
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
class PluginCredentialCatalog {
|
||||
constructor(options = {}) {
|
||||
this.safeStorage = options.safeStorage ?? null;
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
#assertAvailable() {
|
||||
const backend = this.safeStorage?.getSelectedStorageBackend?.();
|
||||
if (!this.safeStorage?.isEncryptionAvailable?.()
|
||||
|| backend === "basic_text"
|
||||
|| typeof this.safeStorage.decryptString !== "function") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Secure OS-backed Vault credential decryption is unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
update(entries) {
|
||||
if (!Array.isArray(entries) || entries.length > MAX_CREDENTIAL_CATALOG_ENTRIES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog is invalid or too large");
|
||||
}
|
||||
try {
|
||||
this.#assertAvailable();
|
||||
} catch (error) {
|
||||
this.records.clear();
|
||||
throw error;
|
||||
}
|
||||
const next = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|
||||
|| Object.keys(entry).some((key) => key !== "id" && key !== "ciphertext")) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog entry is invalid");
|
||||
}
|
||||
const id = assertCredentialId(entry.id);
|
||||
if (next.has(id)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog contains duplicate references");
|
||||
next.set(id, Buffer.from(decodeCiphertext(entry.ciphertext)));
|
||||
}
|
||||
this.records = next;
|
||||
return this.records.size;
|
||||
}
|
||||
|
||||
async assertReference(reference) {
|
||||
const id = assertCredentialId(reference?.id);
|
||||
if (!this.records.has(id)) throw new PluginRpcError(RPC_ERRORS.notFound, "Vault credential reference was not found");
|
||||
}
|
||||
|
||||
async resolve(reference) {
|
||||
this.#assertAvailable();
|
||||
const id = assertCredentialId(reference?.id);
|
||||
const ciphertext = this.records.get(id);
|
||||
if (!ciphertext) throw new PluginRpcError(RPC_ERRORS.notFound, "Vault credential reference was not found");
|
||||
let value;
|
||||
try { value = this.safeStorage.decryptString(Buffer.from(ciphertext)); }
|
||||
catch { throw new PluginRpcError(RPC_ERRORS.dataLoss, "Vault credential could not be decrypted"); }
|
||||
if (typeof value !== "string" || value.length < 1 || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Vault credential is invalid or too large");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.records.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENCRYPTED_CREDENTIAL_PREFIX,
|
||||
MAX_CREDENTIAL_CATALOG_ENTRIES,
|
||||
PluginCredentialCatalog,
|
||||
assertCredentialId,
|
||||
};
|
||||
68
electron/plugins/credentialCatalog.test.cjs
Normal file
68
electron/plugins/credentialCatalog.test.cjs
Normal file
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginCredentialCatalog } = require("./credentialCatalog.cjs");
|
||||
|
||||
function encrypted(value) {
|
||||
return `enc:v1:${Buffer.from(`cipher:${value}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
test("Vault credential catalog keeps only encrypted opaque references and decrypts on lease consumption", async () => {
|
||||
const catalog = new PluginCredentialCatalog({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
getSelectedStorageBackend: () => "keychain",
|
||||
decryptString: (value) => value.toString().replace(/^cipher:/u, ""),
|
||||
},
|
||||
});
|
||||
assert.equal(catalog.update([{
|
||||
id: "credential-reference-0001",
|
||||
ciphertext: encrypted("correct horse battery staple"),
|
||||
}]), 1);
|
||||
await catalog.assertReference({ kind: "credential", id: "credential-reference-0001" });
|
||||
assert.equal(
|
||||
await catalog.resolve({ kind: "credential", id: "credential-reference-0001" }),
|
||||
"correct horse battery staple",
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.assertReference({ kind: "credential", id: "credential-reference-missing" }),
|
||||
/not found/i,
|
||||
);
|
||||
assert.throws(() => catalog.update([{
|
||||
id: "credential-reference-0002",
|
||||
ciphertext: "plaintext-secret",
|
||||
}]), /OS-backed encryption/i);
|
||||
});
|
||||
|
||||
test("Vault credential catalog fails closed when secure storage is unavailable", async () => {
|
||||
let encryptionAvailable = true;
|
||||
let backend = "keychain";
|
||||
const catalog = new PluginCredentialCatalog({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => encryptionAvailable,
|
||||
getSelectedStorageBackend: () => backend,
|
||||
decryptString: (value) => value.toString().replace(/^cipher:/u, ""),
|
||||
},
|
||||
});
|
||||
const entry = {
|
||||
id: "credential-reference-0001",
|
||||
ciphertext: encrypted("secret"),
|
||||
};
|
||||
assert.equal(catalog.update([entry]), 1);
|
||||
backend = "basic_text";
|
||||
assert.throws(() => catalog.update([entry]), /unavailable/i);
|
||||
await assert.rejects(
|
||||
catalog.assertReference({ kind: "credential", id: "credential-reference-0001" }),
|
||||
/not found/i,
|
||||
);
|
||||
|
||||
backend = "keychain";
|
||||
encryptionAvailable = false;
|
||||
assert.throws(() => catalog.update([entry]), /unavailable/i);
|
||||
await assert.rejects(
|
||||
catalog.resolve({ kind: "credential", id: "credential-reference-0001" }),
|
||||
/unavailable|not found/i,
|
||||
);
|
||||
});
|
||||
1074
electron/plugins/database.cjs
Normal file
1074
electron/plugins/database.cjs
Normal file
File diff suppressed because it is too large
Load Diff
835
electron/plugins/database.test.cjs
Normal file
835
electron/plugins/database.test.cjs
Normal file
@@ -0,0 +1,835 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const {
|
||||
MAX_SECURITY_AUDIT_DETAILS_BYTES,
|
||||
PluginDatabase,
|
||||
SCHEMA_VERSION,
|
||||
} = require("./database.cjs");
|
||||
|
||||
function createDatabase(context, clock = () => 1_000) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-db-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return new PluginDatabase(path.join(root, "plugins.sqlite"), { clock });
|
||||
}
|
||||
|
||||
function manifest(id = "com.example.test", version = "1.0.0") {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
name: "test",
|
||||
version,
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "dist/index.js" },
|
||||
};
|
||||
}
|
||||
|
||||
test("plugin database initializes atomically and rejects newer schemas", (context) => {
|
||||
const database = createDatabase(context);
|
||||
assert.equal(database.db.prepare("PRAGMA user_version").get().user_version, SCHEMA_VERSION);
|
||||
assert.equal(database.db.prepare("PRAGMA foreign_keys").get().foreign_keys, 1);
|
||||
database.close();
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-newer-db-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
const newer = new DatabaseSync(file);
|
||||
newer.exec("PRAGMA user_version = 99");
|
||||
newer.close();
|
||||
assert.throws(() => new PluginDatabase(file), /newer than supported/);
|
||||
});
|
||||
|
||||
test("obsolete unpublished v1 layouts fail with an explicit reset instruction", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-obsolete-db-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
const obsolete = new DatabaseSync(file);
|
||||
obsolete.exec("CREATE TABLE plugins(id TEXT PRIMARY KEY); PRAGMA user_version = 1");
|
||||
obsolete.close();
|
||||
assert.throws(
|
||||
() => new PluginDatabase(file),
|
||||
/reset userData\/plugins\/plugins\.sqlite/,
|
||||
);
|
||||
});
|
||||
|
||||
test("complete schema-1 databases migrate in place to schema 3 with sidecar and binding tables", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
const v1 = new DatabaseSync(file);
|
||||
// Full pre-sidecar schema-1 layout (all tables except plugin_sync_sidecars).
|
||||
v1.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
active_version TEXT,
|
||||
installed_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE plugin_versions (
|
||||
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
|
||||
version TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
archive_sha256 TEXT NOT NULL,
|
||||
package_relative_path TEXT NOT NULL,
|
||||
installed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, version)
|
||||
);
|
||||
CREATE TABLE plugin_runtime_state (
|
||||
plugin_id TEXT NOT NULL,
|
||||
plugin_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
runtime_kind TEXT,
|
||||
last_error TEXT,
|
||||
quarantined_at INTEGER,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, plugin_version),
|
||||
FOREIGN KEY (plugin_id, plugin_version)
|
||||
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE plugin_crashes (
|
||||
plugin_id TEXT NOT NULL,
|
||||
plugin_version TEXT NOT NULL,
|
||||
crashed_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (plugin_id, plugin_version)
|
||||
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE plugin_kv (
|
||||
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, key)
|
||||
);
|
||||
CREATE TABLE plugin_settings (
|
||||
plugin_id TEXT NOT NULL,
|
||||
setting_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('application', 'workspace', 'host', 'session', 'device')),
|
||||
scope_id TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, setting_id, scope, scope_id)
|
||||
);
|
||||
CREATE TABLE plugin_view_state (
|
||||
plugin_id TEXT NOT NULL,
|
||||
view_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, view_id, scope_id)
|
||||
);
|
||||
CREATE TABLE plugin_permission_grants (
|
||||
plugin_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('exact', 'directory')),
|
||||
declaration_hash TEXT NOT NULL,
|
||||
granted_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, permission, resource)
|
||||
);
|
||||
CREATE TABLE plugin_secrets (
|
||||
plugin_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL UNIQUE,
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (plugin_id, key)
|
||||
);
|
||||
CREATE TABLE plugin_security_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
`);
|
||||
v1.prepare(
|
||||
"INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at) VALUES (?, 1, ?, 1, 1)",
|
||||
).run("com.example.v1", "1.0.0");
|
||||
v1.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
assert.equal(database.db.prepare("PRAGMA user_version").get().user_version, SCHEMA_VERSION);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
|
||||
["plugin_id", "kind", "key", "value_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
|
||||
["provider_id", "plugin_id", "created_at", "updated_at"],
|
||||
);
|
||||
// Existing rows survive the in-place migration.
|
||||
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
|
||||
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
|
||||
assert.equal(
|
||||
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
|
||||
"dark",
|
||||
);
|
||||
database.upsertSyncProviderBinding("com.example.v1.sync", "com.example.v1");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.v1.sync")?.pluginId, "com.example.v1");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("initial schema scopes runtime and crash state to immutable plugin versions", (context) => {
|
||||
const database = createDatabase(context);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_crashes)").all().map(({ name }) => name),
|
||||
["plugin_id", "plugin_version", "crashed_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_runtime_state)").all().map(({ name }) => name),
|
||||
[
|
||||
"plugin_id",
|
||||
"plugin_version",
|
||||
"status",
|
||||
"runtime_kind",
|
||||
"last_error",
|
||||
"quarantined_at",
|
||||
"updated_at",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_permission_grants)").all().map(({ name }) => name),
|
||||
["plugin_id", "permission", "resource", "resource_kind", "declaration_hash", "granted_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_secrets)").all().map(({ name }) => name),
|
||||
["plugin_id", "key", "secret_ref", "ciphertext", "created_at", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_settings)").all().map(({ name }) => name),
|
||||
["plugin_id", "setting_id", "scope", "scope_id", "value_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_view_state)").all().map(({ name }) => name),
|
||||
["plugin_id", "view_id", "scope_id", "state_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
|
||||
["plugin_id", "kind", "key", "value_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
|
||||
["provider_id", "plugin_id", "created_at", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_settings)").all(), []);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_view_state)").all(), []);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("user-owned security records survive package uninstall in the complete v1 schema", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const pluginManifest = manifest();
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${pluginManifest.id}/${pluginManifest.version}/package`,
|
||||
});
|
||||
database.upsertPermissionGrant({
|
||||
pluginId: pluginManifest.id,
|
||||
permission: "network",
|
||||
resource: "https://example.com",
|
||||
resourceKind: "exact",
|
||||
declarationHash: "b".repeat(64),
|
||||
});
|
||||
database.upsertSecret({
|
||||
pluginId: pluginManifest.id,
|
||||
key: "api-key",
|
||||
secretRef: "secret-reference-0000000000000000",
|
||||
ciphertext: Buffer.from("encrypted"),
|
||||
});
|
||||
database.recordSecurityAudit(pluginManifest.id, "permission.granted", { permission: "network" });
|
||||
|
||||
database.removePlugin(pluginManifest.id);
|
||||
|
||||
assert.equal(database.getActivePlugin(pluginManifest.id), null);
|
||||
assert.deepEqual(database.listPermissionGrants(pluginManifest.id).map((grant) => ({
|
||||
resource: grant.resource,
|
||||
resourceKind: grant.resourceKind,
|
||||
})), [{ resource: "https://example.com", resourceKind: "exact" }]);
|
||||
assert.equal(database.getSecretByKey(pluginManifest.id, "api-key").secretRef, "secret-reference-0000000000000000");
|
||||
assert.deepEqual(database.listSecurityAudit(pluginManifest.id)[0].details, { permission: "network" });
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("security audit details are bounded and oversized records retain only a digest", (context) => {
|
||||
const database = createDatabase(context);
|
||||
database.recordSecurityAudit("com.example.test", "permission.denied", {
|
||||
untrusted: "x".repeat(MAX_SECURITY_AUDIT_DETAILS_BYTES + 1),
|
||||
});
|
||||
const record = database.listSecurityAudit("com.example.test")[0];
|
||||
assert.equal(record.details.truncated, true);
|
||||
assert.match(record.details.sha256, /^[a-f0-9]{64}$/u);
|
||||
assert.equal(Object.hasOwn(record.details, "untrusted"), false);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("version activation and namespaced key/value writes are transactional", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const pluginManifest = manifest();
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "com.example.test/1.0.0/package",
|
||||
}, { enable: true });
|
||||
|
||||
const installed = database.getActivePlugin(pluginManifest.id);
|
||||
assert.equal(installed.enabled, true);
|
||||
assert.equal(installed.activeVersion, "1.0.0");
|
||||
assert.deepEqual(installed.manifest, pluginManifest);
|
||||
|
||||
database.setValue(pluginManifest.id, "greeting", { text: "hello" });
|
||||
database.setValue(pluginManifest.id, "count", 2);
|
||||
assert.deepEqual(database.getValue(pluginManifest.id, "greeting"), { text: "hello" });
|
||||
assert.deepEqual(database.listKeys(pluginManifest.id), ["count", "greeting"]);
|
||||
database.deleteValue(pluginManifest.id, "count");
|
||||
assert.equal(database.getValue(pluginManifest.id, "count"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("database transactions reject async callbacks before committing", (context) => {
|
||||
const database = createDatabase(context);
|
||||
assert.throws(() => database.transaction(async () => {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES ('com.example.async', 0, NULL, 1, 1)
|
||||
`).run();
|
||||
}), /must be synchronous/);
|
||||
assert.equal(database.getActivePlugin("com.example.async"), null);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("recovered versions can atomically replace an enabled version while staying disabled", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const first = manifest();
|
||||
database.installVersion({
|
||||
pluginId: first.id,
|
||||
version: first.version,
|
||||
manifest: first,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${first.id}/${first.version}/package`,
|
||||
}, { enable: true });
|
||||
const second = manifest(first.id, "2.0.0");
|
||||
database.installVersion({
|
||||
pluginId: second.id,
|
||||
version: second.version,
|
||||
manifest: second,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${second.id}/${second.version}/package`,
|
||||
}, { forceDisabled: true });
|
||||
|
||||
const recovered = database.getActivePlugin(first.id);
|
||||
assert.equal(recovered.activeVersion, "2.0.0");
|
||||
assert.equal(recovered.enabled, false);
|
||||
assert.throws(() => database.installVersion({
|
||||
pluginId: second.id,
|
||||
version: second.version,
|
||||
manifest: second,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${second.id}/${second.version}/package`,
|
||||
}, { enable: true, forceDisabled: true }), /cannot be enabled and force-disabled/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("active version rollback is compare-and-set and keeps version state isolated", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const first = manifest();
|
||||
const second = manifest(first.id, "2.0.0");
|
||||
for (const [pluginManifest, archive] of [[first, "a"], [second, "b"]]) {
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: archive.repeat(64),
|
||||
packageRelativePath: `${pluginManifest.id}/${pluginManifest.version}/package`,
|
||||
}, { enable: true });
|
||||
}
|
||||
database.setRuntimeState(first.id, "error", {
|
||||
pluginVersion: second.version,
|
||||
error: "new version failed",
|
||||
});
|
||||
|
||||
const restored = database.setActiveVersion(first.id, first.version, {
|
||||
enabled: true,
|
||||
expectedActiveVersion: second.version,
|
||||
});
|
||||
assert.equal(restored.activeVersion, first.version);
|
||||
assert.equal(restored.enabled, true);
|
||||
assert.equal(restored.runtime.status, "stopped");
|
||||
assert.equal(database.getVersion(first.id, second.version).version, second.version);
|
||||
assert.throws(() => database.setActiveVersion(first.id, second.version, {
|
||||
enabled: true,
|
||||
expectedActiveVersion: "3.0.0",
|
||||
}), /changed before it could be restored/);
|
||||
assert.throws(() => database.setActiveVersion(first.id, "9.0.0"), /version is not installed/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("three crashes inside five minutes quarantine until explicit recovery", (context) => {
|
||||
let now = 10_000;
|
||||
const database = createDatabase(context, () => now);
|
||||
const pluginManifest = manifest();
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "com.example.test/1.0.0/package",
|
||||
});
|
||||
|
||||
assert.deepEqual(database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3), {
|
||||
count: 1, quarantined: false, quarantinedAt: null,
|
||||
});
|
||||
now += 1_000;
|
||||
assert.equal(database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3).quarantined, false);
|
||||
now += 1_000;
|
||||
assert.equal(database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3).quarantined, true);
|
||||
assert.equal(database.getActivePlugin(pluginManifest.id).runtime.status, "quarantined");
|
||||
|
||||
database.clearQuarantine(pluginManifest.id);
|
||||
assert.equal(database.getActivePlugin(pluginManifest.id).runtime.quarantinedAt, null);
|
||||
assert.equal(database.getActivePlugin(pluginManifest.id).runtime.status, "stopped");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("activating a new version resets runtime quarantine without forgiving the same version", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const first = manifest();
|
||||
database.installVersion({
|
||||
pluginId: first.id,
|
||||
version: first.version,
|
||||
manifest: first,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${first.id}/${first.version}/package`,
|
||||
}, { enable: true });
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
database.recordCrash(first.id, first.version, 300_000, 3);
|
||||
}
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.status, "quarantined");
|
||||
|
||||
database.installVersion({
|
||||
pluginId: first.id,
|
||||
version: first.version,
|
||||
manifest: first,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${first.id}/${first.version}/package`,
|
||||
});
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.status, "quarantined");
|
||||
|
||||
const second = manifest(first.id, "2.0.0");
|
||||
database.installVersion({
|
||||
pluginId: second.id,
|
||||
version: second.version,
|
||||
manifest: second,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${second.id}/${second.version}/package`,
|
||||
});
|
||||
const active = database.getActivePlugin(first.id);
|
||||
assert.equal(active.activeVersion, "2.0.0");
|
||||
assert.equal(active.runtime.status, "stopped");
|
||||
assert.equal(active.runtime.lastError, null);
|
||||
assert.equal(active.runtime.quarantinedAt, null);
|
||||
assert.deepEqual(database.recordCrash(second.id, second.version, 300_000, 3), {
|
||||
count: 1,
|
||||
quarantined: false,
|
||||
quarantinedAt: null,
|
||||
});
|
||||
assert.deepEqual(database.recordCrash(first.id, first.version, 300_000, 3), {
|
||||
count: 4,
|
||||
quarantined: true,
|
||||
quarantinedAt: 1_000,
|
||||
});
|
||||
database.clearQuarantine(second.id);
|
||||
assert.equal(Number(database.db.prepare(`
|
||||
SELECT COUNT(*) AS count FROM plugin_crashes
|
||||
WHERE plugin_id = ? AND plugin_version = ?
|
||||
`).get(first.id, first.version).count), 4);
|
||||
assert.equal(Number(database.db.prepare(`
|
||||
SELECT COUNT(*) AS count FROM plugin_crashes
|
||||
WHERE plugin_id = ? AND plugin_version = ?
|
||||
`).get(second.id, second.version).count), 0);
|
||||
database.installVersion({
|
||||
pluginId: first.id,
|
||||
version: first.version,
|
||||
manifest: first,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${first.id}/${first.version}/package`,
|
||||
});
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.status, "quarantined");
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.quarantinedAt, 1_000);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("schema upgrade backfills bindings from legacy sync-provider-map secrets", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v2-backfill-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
// Bootstrap a schema-2 DB then open with PluginDatabase to migrate to v3.
|
||||
const seed = new PluginDatabase(file);
|
||||
// Force downgrade-like state: empty bindings + a legacy map secret row.
|
||||
seed.db.exec("DELETE FROM plugin_sync_provider_bindings");
|
||||
const now = Date.now();
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
// Constructor no longer promotes; hostService seeds after package recovery.
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "com.example");
|
||||
// Consumed map marker so later unbind + reopen cannot re-promote.
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promoted map markers must be deleted",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not overwrite an existing host binding", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Correct owner already bound.
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "com.example.sync");
|
||||
// Stale parent leftover map secret that would steal ownership if upserted.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "must not promote over an existing binding");
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"existing binding must be preserved",
|
||||
);
|
||||
// Skipped promote leaves non-winning map rows in place (only winners are consumed).
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"non-promoted leftover map rows stay until unbind consumes them",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when no candidate holds sync credentials", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Map-only leftovers: longest namespace must not invent ownership without
|
||||
// sync-credential* evidence (parent may be the true owner later).
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Unbound maps stay so a later credential-backed promote or live put can resolve.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill binds sole credential-backed owner when maps conflict", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Parent has leftover map only; nested owner still holds credentials.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "real-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"sole credential-backed owner must win over map-only parent",
|
||||
);
|
||||
// All candidate map markers for the promoted provider are consumed.
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not let longest map override credential-backed parent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// True owner is the shorter parent (still has credentials). Nested plugin has
|
||||
// only a stale map row - longest map alone must not steal the binding.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example",
|
||||
"credential-backed parent must win over longer map-only nested id",
|
||||
);
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when multiple credential-backed candidates exist", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Both parent and nested hold credentials + map for the same provider.
|
||||
// Longest pluginId is not a reliable owner signal — the shorter parent may
|
||||
// be the legitimate owner — so leave unbound rather than guessing.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "nested-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Maps stay so a live put / explicit bind can resolve ownership later.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill still consumes maps after unbind tombstone blocks promote", (context) => {
|
||||
// Verify prior fix: explicit unbind tombstone blocks re-promote; maps may remain
|
||||
// until unbind/delete paths consume them (backfill itself skips and does not
|
||||
// delete when promote is blocked).
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"",
|
||||
"unbind tombstone must block credential-backed map promote",
|
||||
);
|
||||
// Map row stays; disconnect/unbind consumption is responsible for cleanup.
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"skipped promote leaves map markers for unbind to consume",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill deletes promoted map markers so unbind cannot resurrect", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
// Credential evidence required to promote; non-map secrets must survive map consume.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.custom")?.pluginId, "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.custom"),
|
||||
null,
|
||||
"promoted map markers must be deleted to stop post-unbind resurrection",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not resurrect after explicit unbind tombstone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Pre-seed a leftover map after an explicit empty-plugin_id unbind tombstone.
|
||||
database.upsertSyncProviderBinding("com.example.sync", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync")?.pluginId,
|
||||
"",
|
||||
"empty-plugin_id unbind tombstone must block re-promotion",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
test("listInstalledVersions includes inactive package manifests", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Install two versions under one plugin id via raw rows if helpers are heavy.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, 1, ?, ?, ?)
|
||||
`).run("com.example", "2.0.0", now, now);
|
||||
for (const [version, providerId] of [
|
||||
["1.0.0", "com.example.legacy-sync"],
|
||||
["2.0.0", "com.example.sync"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_versions(
|
||||
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
version,
|
||||
JSON.stringify({
|
||||
id: "com.example",
|
||||
version,
|
||||
contributes: { providers: [{ id: providerId, kind: "sync", label: providerId }] },
|
||||
}),
|
||||
"a".repeat(64),
|
||||
`com.example/${version}/package`,
|
||||
now,
|
||||
);
|
||||
}
|
||||
const versions = database.listInstalledVersions();
|
||||
assert.equal(versions.length, 2);
|
||||
const providerIds = new Set();
|
||||
for (const v of versions) {
|
||||
for (const p of v.manifest?.contributes?.providers ?? []) {
|
||||
if (p.kind === "sync") providerIds.add(p.id);
|
||||
}
|
||||
}
|
||||
assert.ok(providerIds.has("com.example.legacy-sync"));
|
||||
assert.ok(providerIds.has("com.example.sync"));
|
||||
// Active list only has 2.0.0
|
||||
assert.equal(database.listPlugins()[0]?.activeVersion, "2.0.0");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("inferPluginIdForSyncProvider never guesses from credential key prefixes", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-credential"],
|
||||
["com.example.backup", "sync-credential"],
|
||||
["com.other", "sync-credential"],
|
||||
["com.example.sync", "sync-credential"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.backup.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.other.cloud"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
114
electron/plugins/electronRuntime.live.test.cjs
Normal file
114
electron/plugins/electronRuntime.live.test.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
"use strict";
|
||||
|
||||
if (!process.versions.electron) {
|
||||
const test = require("node:test");
|
||||
test("live Electron plugin runtimes", { skip: "run with npm run test:plugin-runtime:electron" }, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const electron = require("electron");
|
||||
|
||||
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
|
||||
const { inspectContributionIconSource } = require("./contributionIconService.cjs");
|
||||
const { createIsolatedContributionIconRasterizer } = require("./contributionIconRasterizer.cjs");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
|
||||
const appRoot = path.resolve(__dirname, "..", "..");
|
||||
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-electron-smoke-"));
|
||||
electron.app.setPath("userData", userData);
|
||||
electron.app.commandLine.appendSwitch("disable-gpu");
|
||||
electron.app.on("window-all-closed", () => {});
|
||||
electron.protocol.registerSchemesAsPrivileged([{
|
||||
scheme: PLUGIN_PROTOCOL_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: false,
|
||||
codeCache: true,
|
||||
},
|
||||
}]);
|
||||
|
||||
void electron.app.whenReady().then(async () => {
|
||||
const rasterizeContributionIcon = createIsolatedContributionIconRasterizer({
|
||||
BrowserWindow: electron.BrowserWindow,
|
||||
});
|
||||
const rasterizedIcon = await rasterizeContributionIcon({
|
||||
body: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 64"><rect width="128" height="64" fill="red"/></svg>'),
|
||||
mimeType: "image/svg+xml",
|
||||
width: 128,
|
||||
height: 64,
|
||||
maxEdge: 64,
|
||||
});
|
||||
assert.deepEqual(inspectContributionIconSource(rasterizedIcon, ".png", 64), {
|
||||
mimeType: "image/png",
|
||||
width: 64,
|
||||
height: 32,
|
||||
});
|
||||
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const fixtures = [
|
||||
{ id: "com.netcatty.runtime-smoke-browser", kind: "browser" },
|
||||
{ id: "com.netcatty.runtime-smoke-utility", kind: "utility" },
|
||||
];
|
||||
const runtimeAppRoot = path.join(userData, "runtime-app");
|
||||
const runtimeDirectory = path.join(runtimeAppRoot, "electron", "plugins", "runtime");
|
||||
for (const packageName of ["plugin-contract", "plugin-sdk"]) {
|
||||
fs.cpSync(
|
||||
path.join(appRoot, "node_modules", "@netcatty", packageName, "dist"),
|
||||
path.join(runtimeAppRoot, "node_modules", "@netcatty", packageName, "dist"),
|
||||
{ recursive: true },
|
||||
);
|
||||
}
|
||||
fs.cpSync(path.join(__dirname, "runtime"), runtimeDirectory, { recursive: true });
|
||||
const service = createPluginHostService({
|
||||
app: electron.app,
|
||||
electron,
|
||||
appRoot: runtimeAppRoot,
|
||||
runtimeDirectory,
|
||||
requestPermissionDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "application",
|
||||
}),
|
||||
});
|
||||
await service.manager.initialize();
|
||||
for (const fixture of fixtures) {
|
||||
const archivePath = path.join(userData, `${fixture.id}.ncpkg`);
|
||||
await buildPluginPackage(
|
||||
path.join(__dirname, "fixtures", `${fixture.kind}-runtime-plugin`),
|
||||
archivePath,
|
||||
);
|
||||
const installed = await service.manager.install(archivePath, { enable: true });
|
||||
assert.equal(installed.id, fixture.id);
|
||||
const active = service.database.getActivePlugin(fixture.id);
|
||||
assert.equal(active.runtime.status, "running");
|
||||
assert.equal(active.runtime.kind, fixture.kind);
|
||||
assert.deepEqual(service.database.getValue(fixture.id, "smoke.activation"), {
|
||||
kind: fixture.kind,
|
||||
ready: true,
|
||||
});
|
||||
}
|
||||
await service.manager.shutdown();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
process.stdout.write("PLUGIN_RUNTIME_SMOKE_OK\n");
|
||||
electron.app.exit(0);
|
||||
}).catch(async (error) => {
|
||||
console.error(error);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
for (const pluginId of [
|
||||
"com.netcatty.runtime-smoke-browser",
|
||||
"com.netcatty.runtime-smoke-utility",
|
||||
]) {
|
||||
try {
|
||||
const logPath = path.join(userData, "plugins", "logs", pluginId, "runtime.log");
|
||||
console.error(fs.readFileSync(logPath, "utf8"));
|
||||
} catch {}
|
||||
}
|
||||
if (process.env.NETCATTY_PLUGIN_SMOKE_KEEP) console.error(`Smoke data retained at ${userData}`);
|
||||
else fs.rmSync(userData, { recursive: true, force: true });
|
||||
electron.app.exit(1);
|
||||
});
|
||||
}
|
||||
1366
electron/plugins/extensionProviderService.cjs
Normal file
1366
electron/plugins/extensionProviderService.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1084
electron/plugins/extensionProviderService.test.cjs
Normal file
1084
electron/plugins/extensionProviderService.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
367
electron/plugins/filesystemBroker.cjs
Normal file
367
electron/plugins/filesystemBroker.cjs
Normal file
@@ -0,0 +1,367 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_FILESYSTEM_BYTES = PLUGIN_RPC_MAX_RAW_BYTES;
|
||||
const MAX_DIRECTORY_ENTRIES = 1_000;
|
||||
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
function assertAbsolutePath(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 8_192
|
||||
|| value.includes("\0")
|
||||
|| !path.isAbsolute(value)
|
||||
) {
|
||||
throw invalidArgument("Plugin filesystem path must be absolute");
|
||||
}
|
||||
// Resolve absolute paths lexically so permission resources have one stable
|
||||
// spelling. In particular, realpath() drops trailing separators, and the
|
||||
// post-permission equality checks must compare against the same spelling.
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function assertReadParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem read parameters are invalid");
|
||||
}
|
||||
const encoding = params.encoding ?? "utf8";
|
||||
if (encoding !== "utf8" && encoding !== "base64") {
|
||||
throw invalidArgument("Plugin filesystem read encoding is invalid");
|
||||
}
|
||||
const maxBytes = params.maxBytes ?? MAX_FILESYSTEM_BYTES;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_FILESYSTEM_BYTES) {
|
||||
throw invalidArgument("Plugin filesystem read limit is invalid");
|
||||
}
|
||||
return { path: assertAbsolutePath(params.path), encoding, maxBytes };
|
||||
}
|
||||
|
||||
function decodeWriteData(params) {
|
||||
const encoding = params.encoding ?? "utf8";
|
||||
if (encoding !== "utf8" && encoding !== "base64") {
|
||||
throw invalidArgument("Plugin filesystem write encoding is invalid");
|
||||
}
|
||||
if (typeof params.data !== "string") throw invalidArgument("Plugin filesystem write data is invalid");
|
||||
const bytes = Buffer.from(params.data, encoding);
|
||||
if (encoding === "base64" && bytes.toString("base64") !== params.data) {
|
||||
throw invalidArgument("Plugin filesystem base64 data is not canonical");
|
||||
}
|
||||
if (bytes.byteLength > MAX_FILESYSTEM_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem write is too large");
|
||||
}
|
||||
return { encoding, bytes };
|
||||
}
|
||||
|
||||
function assertWriteParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem write parameters are invalid");
|
||||
}
|
||||
const { encoding, bytes } = decodeWriteData(params);
|
||||
if (params.overwrite !== undefined && typeof params.overwrite !== "boolean") {
|
||||
throw invalidArgument("Plugin filesystem overwrite flag is invalid");
|
||||
}
|
||||
return {
|
||||
path: assertAbsolutePath(params.path),
|
||||
encoding,
|
||||
bytes,
|
||||
overwrite: params.overwrite === true,
|
||||
};
|
||||
}
|
||||
|
||||
function assertSecureWriteMode(value) {
|
||||
if (!value.overwrite) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Plugin filesystem writes require explicit overwrite of an existing file",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPathParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin filesystem parameters are invalid");
|
||||
}
|
||||
return { path: assertAbsolutePath(params.path) };
|
||||
}
|
||||
|
||||
async function resolveExistingPath(requestedPath, fileSystem = fsp) {
|
||||
const canonical = await fileSystem.realpath(requestedPath);
|
||||
const stats = await fileSystem.lstat(canonical);
|
||||
if (stats.isSymbolicLink()) throw invalidArgument("Plugin filesystem path cannot resolve to a symbolic link");
|
||||
return { canonical, stats };
|
||||
}
|
||||
|
||||
async function resolveWritePath(requestedPath, fileSystem = fsp) {
|
||||
try {
|
||||
const existing = await resolveExistingPath(requestedPath, fileSystem);
|
||||
if (!existing.stats.isFile()) throw invalidArgument("Plugin filesystem write target is not a file");
|
||||
return existing;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Plugin filesystem writes require an existing regular file",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKind(entry) {
|
||||
if (entry.isFile()) return "file";
|
||||
if (entry.isDirectory()) return "directory";
|
||||
return "other";
|
||||
}
|
||||
|
||||
async function readBoundedFileHandle(handle, maxBytes) {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (total <= maxBytes) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, (maxBytes + 1) - total));
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(buffer.subarray(0, bytesRead));
|
||||
total += bytesRead;
|
||||
}
|
||||
if (total > maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function assertAuthorizedPath(context, permission, canonical) {
|
||||
const authorization = context?.authorization;
|
||||
if (
|
||||
authorization?.permission !== permission
|
||||
|| !Array.isArray(authorization.resources)
|
||||
|| authorization.resources.length !== 1
|
||||
|| authorization.resources[0] !== canonical
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem path changed after authorization",
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function assertSameOpenedFile(openedStats, pathStats) {
|
||||
if (
|
||||
!openedStats.isFile()
|
||||
|| !pathStats.isFile()
|
||||
|| openedStats.dev !== pathStats.dev
|
||||
|| openedStats.ino !== pathStats.ino
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem target changed after it was opened",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameOpenedDirectory(expectedStats, pathStats) {
|
||||
if (
|
||||
!expectedStats.isDirectory()
|
||||
|| !pathStats.isDirectory()
|
||||
|| expectedStats.dev !== pathStats.dev
|
||||
|| expectedStats.ino !== pathStats.ino
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin filesystem directory changed after authorization",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
|
||||
throw new TypeError("Secure plugin directory handle adapter must be a function");
|
||||
}
|
||||
}
|
||||
|
||||
validateRead(params) { return assertReadParams(params); }
|
||||
validateWrite(params) {
|
||||
const value = assertWriteParams(params);
|
||||
return {
|
||||
path: value.path,
|
||||
encoding: value.encoding,
|
||||
data: value.bytes.toString(value.encoding),
|
||||
overwrite: value.overwrite,
|
||||
};
|
||||
}
|
||||
validatePath(params) { return assertPathParams(params); }
|
||||
|
||||
describeReadAuthorization(params, resourceKind = "exact") {
|
||||
const value = assertPathParams(params);
|
||||
if (resourceKind !== "exact" && resourceKind !== "directory") {
|
||||
throw invalidArgument("Plugin filesystem authorization kind is invalid");
|
||||
}
|
||||
return {
|
||||
permission: "filesystem.read",
|
||||
resources: [value.path],
|
||||
resourceKinds: [resourceKind],
|
||||
reason: `Read ${value.path}`,
|
||||
operationId: `filesystem.read:${value.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
describeWriteAuthorization(params) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
return {
|
||||
permission: "filesystem.write",
|
||||
resources: [value.path],
|
||||
resourceKinds: ["exact"],
|
||||
reason: `Write ${value.path}`,
|
||||
operationId: `filesystem.write:${value.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(params, context) {
|
||||
const value = assertReadParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file");
|
||||
if (stats.size > value.maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file is too large");
|
||||
}
|
||||
const handle = await this.fileSystem.open(canonical, fs.constants.O_RDONLY | NOFOLLOW);
|
||||
try {
|
||||
const [openedStats, pathStats] = await Promise.all([
|
||||
handle.stat(),
|
||||
this.fileSystem.stat(canonical),
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
if (openedStats.size > value.maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
|
||||
}
|
||||
assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical));
|
||||
const bytes = await readBoundedFileHandle(handle, value.maxBytes);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength);
|
||||
await context.assertActive();
|
||||
return { data: bytes.toString(value.encoding) };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
assertAuthorizedPath(context, "filesystem.write", value.path);
|
||||
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.write", canonical);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
|
||||
await context.assertActive();
|
||||
const flags = fs.constants.O_WRONLY | NOFOLLOW;
|
||||
const handle = await this.fileSystem.open(canonical, flags, 0o600);
|
||||
try {
|
||||
assertAuthorizedPath(context, "filesystem.write", await this.fileSystem.realpath(canonical));
|
||||
const [openedStats, pathStats] = await Promise.all([
|
||||
handle.stat(),
|
||||
this.fileSystem.stat(canonical),
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
await context.assertActive();
|
||||
if (value.overwrite) await handle.truncate(0);
|
||||
await handle.writeFile(value.bytes);
|
||||
await handle.sync();
|
||||
await context.assertActive();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async stat(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
await context.assertActive();
|
||||
return {
|
||||
kind: stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other",
|
||||
size: Math.min(Number.MAX_SAFE_INTEGER, Number(stats.size)),
|
||||
modifiedAt: Math.max(0, Math.trunc(stats.mtimeMs)),
|
||||
};
|
||||
}
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
if (!this.openDirectoryHandle) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Secure plugin directory enumeration is unavailable on this runtime",
|
||||
);
|
||||
}
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
if (
|
||||
!directory
|
||||
|| typeof directory.stat !== "function"
|
||||
|| typeof directory.read !== "function"
|
||||
|| typeof directory.close !== "function"
|
||||
) throw new TypeError("Secure plugin directory handle adapter returned an invalid handle");
|
||||
try {
|
||||
assertSameOpenedDirectory(stats, await directory.stat());
|
||||
const entries = [];
|
||||
for (;;) {
|
||||
const entry = await directory.read();
|
||||
if (entry === null) break;
|
||||
entries.push(entry);
|
||||
if (entries.length > MAX_DIRECTORY_ENTRIES) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.resourceExhausted,
|
||||
"Plugin filesystem directory has too many entries",
|
||||
);
|
||||
}
|
||||
}
|
||||
assertSameOpenedDirectory(stats, await directory.stat());
|
||||
assertSameOpenedDirectory(stats, await this.fileSystem.stat(canonical));
|
||||
await context.assertActive();
|
||||
return {
|
||||
entries: entries
|
||||
.map((entry) => ({ name: entry.name, kind: entryKind(entry) }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, "en")),
|
||||
};
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
MAX_FILESYSTEM_BYTES,
|
||||
PluginFilesystemBroker,
|
||||
assertAbsolutePath,
|
||||
assertAuthorizedPath,
|
||||
assertSameOpenedFile,
|
||||
assertSameOpenedDirectory,
|
||||
assertPathParams,
|
||||
assertReadParams,
|
||||
assertSecureWriteMode,
|
||||
assertWriteParams,
|
||||
resolveExistingPath,
|
||||
resolveWritePath,
|
||||
readBoundedFileHandle,
|
||||
};
|
||||
392
electron/plugins/filesystemBroker.test.cjs
Normal file
392
electron/plugins/filesystemBroker.test.cjs
Normal file
@@ -0,0 +1,392 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginFilesystemBroker, MAX_FILESYSTEM_BYTES } = require("./filesystemBroker.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function createRoot(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-filesystem-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return fs.realpathSync(root);
|
||||
}
|
||||
|
||||
function runtimeContext(authorization) {
|
||||
return {
|
||||
runtimeId: "runtime-1",
|
||||
authorization,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function openTestDirectoryHandle(directoryPath) {
|
||||
const stats = await fsp.stat(directoryPath);
|
||||
const directory = await fsp.opendir(directoryPath);
|
||||
return {
|
||||
stat: async () => stats,
|
||||
read: () => directory.read(),
|
||||
close: async () => {
|
||||
try { await directory.close(); }
|
||||
catch (error) { if (error?.code !== "ERR_DIR_CLOSED") throw error; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("filesystem broker uses canonical authorization for bounded read, write, stat, and list", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const source = path.join(root, "source.txt");
|
||||
const target = path.join(root, "target.txt");
|
||||
await Promise.all([
|
||||
fsp.writeFile(source, "hello"),
|
||||
fsp.writeFile(target, "prior"),
|
||||
]);
|
||||
const charges = [];
|
||||
const broker = new PluginFilesystemBroker({
|
||||
quotaManager: { chargeBytes: (...args) => charges.push(args) },
|
||||
openDirectoryHandle: openTestDirectoryHandle,
|
||||
});
|
||||
|
||||
const readAuthorization = await broker.describeReadAuthorization({ path: source });
|
||||
assert.deepEqual(readAuthorization.resourceKinds, ["exact"]);
|
||||
assert.deepEqual(
|
||||
await broker.readFile({ path: source }, runtimeContext(readAuthorization)),
|
||||
{ data: "hello" },
|
||||
);
|
||||
assert.equal((await broker.stat({ path: source }, runtimeContext(readAuthorization))).kind, "file");
|
||||
|
||||
const writeAuthorization = await broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "world",
|
||||
overwrite: true,
|
||||
});
|
||||
assert.deepEqual(writeAuthorization.resourceKinds, ["exact"]);
|
||||
await broker.writeFile({
|
||||
path: target,
|
||||
data: "world",
|
||||
overwrite: true,
|
||||
}, runtimeContext(writeAuthorization));
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "world");
|
||||
const overwriteAuthorization = await broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "short",
|
||||
overwrite: true,
|
||||
});
|
||||
await broker.writeFile(
|
||||
{ path: target, data: "short", overwrite: true },
|
||||
runtimeContext(overwriteAuthorization),
|
||||
);
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "short");
|
||||
|
||||
const listAuthorization = await broker.describeReadAuthorization({ path: root }, "directory");
|
||||
assert.deepEqual(listAuthorization.resourceKinds, ["directory"]);
|
||||
const list = await broker.readDirectory({ path: root }, runtimeContext(listAuthorization));
|
||||
assert.deepEqual(list.entries.map(({ name }) => name), ["source.txt", "target.txt"]);
|
||||
assert.deepEqual(charges, [
|
||||
["runtime-1", "filesystem", 5],
|
||||
["runtime-1", "filesystem", 5],
|
||||
["runtime-1", "filesystem", 5],
|
||||
]);
|
||||
});
|
||||
|
||||
test("filesystem authorization removes trailing directory separators before permission", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const requestedPath = `${root}${path.sep}`;
|
||||
await fsp.writeFile(path.join(root, "entry.txt"), "entry");
|
||||
const broker = new PluginFilesystemBroker({ openDirectoryHandle: openTestDirectoryHandle });
|
||||
const statAuthorization = broker.describeReadAuthorization({ path: requestedPath }, "exact");
|
||||
const listAuthorization = broker.describeReadAuthorization({ path: requestedPath }, "directory");
|
||||
|
||||
assert.deepEqual(statAuthorization.resources, [root]);
|
||||
assert.deepEqual(listAuthorization.resources, [root]);
|
||||
assert.equal(
|
||||
(await broker.stat({ path: requestedPath }, runtimeContext(statAuthorization))).kind,
|
||||
"directory",
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await broker.readDirectory({ path: requestedPath }, runtimeContext(listAuthorization))).entries,
|
||||
[{ name: "entry.txt", kind: "file" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("filesystem write quota is enforced before mutating the target", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "target.txt");
|
||||
await fsp.writeFile(target, "original");
|
||||
const broker = new PluginFilesystemBroker({
|
||||
quotaManager: { chargeBytes: () => { throw new Error("quota denied"); } },
|
||||
});
|
||||
const authorization = await broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "blocked",
|
||||
overwrite: true,
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.writeFile({
|
||||
path: target,
|
||||
data: "blocked",
|
||||
overwrite: true,
|
||||
}, runtimeContext(authorization)),
|
||||
/quota denied/,
|
||||
);
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
test("filesystem authorization descriptors never probe path existence before permission", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const missing = path.join(root, "missing.txt");
|
||||
let filesystemCalls = 0;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
fileSystem: new Proxy({}, {
|
||||
get() {
|
||||
return async () => {
|
||||
filesystemCalls += 1;
|
||||
throw new Error("filesystem must not be queried while describing authorization");
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(broker.describeReadAuthorization({ path: missing }), {
|
||||
permission: "filesystem.read",
|
||||
resources: [missing],
|
||||
resourceKinds: ["exact"],
|
||||
reason: `Read ${missing}`,
|
||||
operationId: `filesystem.read:${missing}`,
|
||||
});
|
||||
assert.deepEqual(broker.describeReadAuthorization({ path: root }, "directory").resourceKinds, ["directory"]);
|
||||
assert.deepEqual(broker.describeWriteAuthorization({
|
||||
path: missing,
|
||||
data: "blocked",
|
||||
overwrite: true,
|
||||
}).resources, [missing]);
|
||||
await assert.rejects(
|
||||
broker.readFile({ path: missing }, runtimeContext(null)),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
await assert.rejects(
|
||||
broker.writeFile(
|
||||
{ path: missing, data: "blocked", overwrite: true },
|
||||
runtimeContext(null),
|
||||
),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(filesystemCalls, 0);
|
||||
});
|
||||
|
||||
test("filesystem writes reject missing targets without opening or creating them", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "missing.txt");
|
||||
let openCalls = 0;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
fileSystem: {
|
||||
...fsp,
|
||||
async open(...args) {
|
||||
openCalls += 1;
|
||||
return fsp.open(...args);
|
||||
},
|
||||
},
|
||||
});
|
||||
const authorization = broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "blocked",
|
||||
overwrite: true,
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.writeFile(
|
||||
{ path: target, data: "blocked", overwrite: true },
|
||||
runtimeContext(authorization),
|
||||
),
|
||||
(error) => error.code === RPC_ERRORS.failedPrecondition,
|
||||
);
|
||||
assert.equal(openCalls, 0);
|
||||
await assert.rejects(fsp.stat(target), (error) => error.code === "ENOENT");
|
||||
});
|
||||
|
||||
test("filesystem path swaps after authorization fail closed", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const first = path.join(root, "first.txt");
|
||||
const second = path.join(root, "second.txt");
|
||||
const link = path.join(root, "selected.txt");
|
||||
await Promise.all([fsp.writeFile(first, "first"), fsp.writeFile(second, "second")]);
|
||||
await fsp.symlink(first, link);
|
||||
const broker = new PluginFilesystemBroker();
|
||||
const authorization = await broker.describeReadAuthorization({ path: link });
|
||||
await fsp.unlink(link);
|
||||
await fsp.symlink(second, link);
|
||||
await assert.rejects(
|
||||
broker.readFile({ path: link }, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
});
|
||||
|
||||
test("filesystem directory replacement between authorization and open fails closed", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const selected = path.join(root, "selected");
|
||||
const replacement = path.join(root, "replacement");
|
||||
const original = path.join(root, "original");
|
||||
await Promise.all([
|
||||
fsp.mkdir(selected),
|
||||
fsp.mkdir(replacement),
|
||||
]);
|
||||
await Promise.all([
|
||||
fsp.writeFile(path.join(selected, "allowed.txt"), "allowed"),
|
||||
fsp.writeFile(path.join(replacement, "secret.txt"), "secret"),
|
||||
]);
|
||||
let swapped = false;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
openDirectoryHandle: async (directoryPath) => {
|
||||
if (!swapped) {
|
||||
swapped = true;
|
||||
await fsp.rename(selected, original);
|
||||
await fsp.rename(replacement, selected);
|
||||
}
|
||||
return openTestDirectoryHandle(directoryPath);
|
||||
},
|
||||
});
|
||||
const authorization = await broker.describeReadAuthorization({ path: selected }, "directory");
|
||||
await assert.rejects(
|
||||
broker.readDirectory({ path: selected }, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
});
|
||||
|
||||
test("filesystem directory listing fails closed without a handle-bound adapter", async (context) => {
|
||||
const root = createRoot(context);
|
||||
await fsp.writeFile(path.join(root, "entry.txt"), "entry");
|
||||
const broker = new PluginFilesystemBroker();
|
||||
const authorization = broker.describeReadAuthorization({ path: root }, "directory");
|
||||
await assert.rejects(
|
||||
broker.readDirectory({ path: root }, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.unsupported,
|
||||
);
|
||||
});
|
||||
|
||||
test("filesystem writes recheck runtime activity immediately before mutation", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "target.txt");
|
||||
await fsp.writeFile(target, "original");
|
||||
let active = true;
|
||||
let armed = false;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
fileSystem: {
|
||||
...fsp,
|
||||
async stat(filePath, options) {
|
||||
const stats = await fsp.stat(filePath, options);
|
||||
if (armed) active = false;
|
||||
return stats;
|
||||
},
|
||||
},
|
||||
});
|
||||
const authorization = await broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "replacement",
|
||||
overwrite: true,
|
||||
});
|
||||
armed = true;
|
||||
await assert.rejects(broker.writeFile({
|
||||
path: target,
|
||||
data: "replacement",
|
||||
overwrite: true,
|
||||
}, {
|
||||
...runtimeContext(authorization),
|
||||
assertActive: async () => { if (!active) throw new Error("runtime stopped"); },
|
||||
}), /runtime stopped/);
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
test("filesystem reads reject a replacement inode opened after authorization", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "selected.txt");
|
||||
const original = path.join(root, "original.txt");
|
||||
const replacement = path.join(root, "replacement.txt");
|
||||
await Promise.all([
|
||||
fsp.writeFile(target, "allowed"),
|
||||
fsp.writeFile(replacement, "secret"),
|
||||
]);
|
||||
let swapped = false;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
fileSystem: {
|
||||
...fsp,
|
||||
async open(filePath, flags, mode) {
|
||||
if (!swapped) {
|
||||
swapped = true;
|
||||
await fsp.rename(target, original);
|
||||
await fsp.rename(replacement, target);
|
||||
}
|
||||
return fsp.open(filePath, flags, mode);
|
||||
},
|
||||
},
|
||||
});
|
||||
const authorization = broker.describeReadAuthorization({ path: target });
|
||||
await assert.rejects(
|
||||
broker.readFile({ path: target }, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
});
|
||||
|
||||
test("filesystem writes reject a replacement inode opened after authorization", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "selected.txt");
|
||||
const original = path.join(root, "original.txt");
|
||||
const replacement = path.join(root, "replacement.txt");
|
||||
await Promise.all([
|
||||
fsp.writeFile(target, "allowed"),
|
||||
fsp.writeFile(replacement, "secret"),
|
||||
]);
|
||||
let swapped = false;
|
||||
const broker = new PluginFilesystemBroker({
|
||||
fileSystem: {
|
||||
...fsp,
|
||||
async open(filePath, flags, mode) {
|
||||
if (!swapped) {
|
||||
swapped = true;
|
||||
await fsp.rename(target, original);
|
||||
await fsp.rename(replacement, target);
|
||||
}
|
||||
return fsp.open(filePath, flags, mode);
|
||||
},
|
||||
},
|
||||
});
|
||||
const authorization = broker.describeWriteAuthorization({
|
||||
path: target,
|
||||
data: "overwritten",
|
||||
overwrite: true,
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.writeFile({
|
||||
path: target,
|
||||
data: "overwritten",
|
||||
overwrite: true,
|
||||
}, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(await fsp.readFile(original, "utf8"), "allowed");
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "secret");
|
||||
});
|
||||
|
||||
test("filesystem broker rejects oversized and non-canonical payloads", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const target = path.join(root, "target.bin");
|
||||
const broker = new PluginFilesystemBroker();
|
||||
assert.throws(() => broker.validateWrite({
|
||||
path: target,
|
||||
data: "YQ",
|
||||
encoding: "base64",
|
||||
}), /not canonical/);
|
||||
assert.throws(() => broker.validateWrite({
|
||||
path: target,
|
||||
data: "a".repeat(MAX_FILESYSTEM_BYTES + 1),
|
||||
}), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
assert.throws(() => broker.validateRead({ path: `${target}\0suffix` }), /absolute/);
|
||||
|
||||
await fsp.writeFile(target, "12345");
|
||||
const authorization = await broker.describeReadAuthorization({ path: target, maxBytes: 4 });
|
||||
await assert.rejects(
|
||||
broker.readFile({ path: target, maxBytes: 4 }, runtimeContext(authorization)),
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
});
|
||||
31
electron/plugins/fixtures/browser-runtime-plugin/index.js
Normal file
31
electron/plugins/fixtures/browser-runtime-plugin/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
async activate(context) {
|
||||
for (const capability of [
|
||||
"fetch",
|
||||
"RTCPeerConnection",
|
||||
"WebSocket",
|
||||
"WebTransport",
|
||||
"Worker",
|
||||
]) {
|
||||
if (globalThis[capability] !== undefined) {
|
||||
throw new Error(`Direct browser capability remains exposed: ${capability}`);
|
||||
}
|
||||
}
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
let childNetworkBlocked = false;
|
||||
try {
|
||||
await iframe.contentWindow.fetch("https://netcatty-plugin-smoke.invalid/");
|
||||
} catch {
|
||||
childNetworkBlocked = true;
|
||||
} finally {
|
||||
iframe.remove();
|
||||
}
|
||||
if (!childNetworkBlocked) {
|
||||
throw new Error("Child browsing context bypassed the offline plugin session");
|
||||
}
|
||||
await context.storage.set("smoke.activation", { kind: "browser", ready: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.runtime-smoke-browser",
|
||||
"name": "runtime-smoke-browser",
|
||||
"version": "1.0.0",
|
||||
"publisher": "netcatty",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "index.js"
|
||||
},
|
||||
"activationEvents": ["onStartupFinished"],
|
||||
"permissions": {
|
||||
"required": ["storage"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
async activate(context) {
|
||||
await context.storage.set("smoke.activation", { kind: "utility", ready: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.runtime-smoke-utility",
|
||||
"name": "runtime-smoke-utility",
|
||||
"version": "1.0.0",
|
||||
"publisher": "netcatty",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"node": "index.js"
|
||||
},
|
||||
"activationEvents": ["onStartupFinished"],
|
||||
"permissions": {
|
||||
"required": ["runtime.advanced", "storage"]
|
||||
}
|
||||
}
|
||||
4308
electron/plugins/generated/plugin-contract.schema.json
Normal file
4308
electron/plugins/generated/plugin-contract.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
33
electron/plugins/hostBootstrap.cjs
Normal file
33
electron/plugins/hostBootstrap.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
|
||||
|
||||
function startDevelopmentPluginHost(options) {
|
||||
if (!isPluginDevelopmentEnabled(options.env)) return null;
|
||||
const reportFailure = (message, error) => {
|
||||
try { options.logger?.error?.(message, error); } catch {}
|
||||
};
|
||||
let service;
|
||||
try {
|
||||
service = options.createService();
|
||||
const initialization = Promise.resolve(service.manager.initialize());
|
||||
options.registerShutdown(() => service.manager.shutdown());
|
||||
void initialization.catch(async (error) => {
|
||||
reportFailure("[Plugins] Failed to initialize plugin host:", error);
|
||||
try {
|
||||
await service.manager.shutdown();
|
||||
} catch (shutdownError) {
|
||||
reportFailure("[Plugins] Failed to close unavailable plugin host:", shutdownError);
|
||||
}
|
||||
});
|
||||
return service;
|
||||
} catch (error) {
|
||||
reportFailure("[Plugins] Failed to create plugin host:", error);
|
||||
if (typeof service?.manager?.shutdown === "function") {
|
||||
try { void Promise.resolve(service.manager.shutdown()).catch(() => {}); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startDevelopmentPluginHost };
|
||||
97
electron/plugins/hostBootstrap.test.cjs
Normal file
97
electron/plugins/hostBootstrap.test.cjs
Normal file
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { startDevelopmentPluginHost } = require("./hostBootstrap.cjs");
|
||||
|
||||
test("disabled development host does not construct plugin infrastructure", () => {
|
||||
let created = 0;
|
||||
const service = startDevelopmentPluginHost({
|
||||
env: {},
|
||||
createService() { created += 1; },
|
||||
registerShutdown() {},
|
||||
logger: { error() {} },
|
||||
});
|
||||
assert.equal(service, null);
|
||||
assert.equal(created, 0);
|
||||
});
|
||||
|
||||
test("synchronous plugin host construction failure leaves Netcatty available", () => {
|
||||
const errors = [];
|
||||
let registered = 0;
|
||||
const service = startDevelopmentPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService() { throw new Error("corrupt plugin database"); },
|
||||
registerShutdown() { registered += 1; },
|
||||
logger: { error(...args) { errors.push(args); } },
|
||||
});
|
||||
assert.equal(service, null);
|
||||
assert.equal(registered, 0);
|
||||
assert.match(errors[0][1].message, /corrupt plugin database/);
|
||||
});
|
||||
|
||||
test("created plugin host initializes and registers one shutdown owner", async () => {
|
||||
let initialized = 0;
|
||||
let shutdown = 0;
|
||||
let shutdownHandler;
|
||||
const service = {
|
||||
manager: {
|
||||
async initialize() { initialized += 1; },
|
||||
async shutdown() { shutdown += 1; },
|
||||
},
|
||||
};
|
||||
assert.equal(startDevelopmentPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => service,
|
||||
registerShutdown(handler) { shutdownHandler = handler; },
|
||||
logger: { error() {} },
|
||||
}), service);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(initialized, 1);
|
||||
await shutdownHandler();
|
||||
assert.equal(shutdown, 1);
|
||||
});
|
||||
|
||||
test("asynchronous initialization failure closes and leaves the host unavailable", async () => {
|
||||
let shutdown = 0;
|
||||
let shutdownHandler;
|
||||
const errors = [];
|
||||
const service = {
|
||||
manager: {
|
||||
initialize: async () => { throw new Error("recovery failed"); },
|
||||
shutdown: async () => { shutdown += 1; },
|
||||
},
|
||||
};
|
||||
assert.equal(startDevelopmentPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => service,
|
||||
registerShutdown(handler) { shutdownHandler = handler; },
|
||||
logger: { error(...args) { errors.push(args); } },
|
||||
}), service);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(shutdown, 1);
|
||||
assert.match(errors[0][1].message, /recovery failed/);
|
||||
await shutdownHandler();
|
||||
assert.equal(shutdown, 2);
|
||||
});
|
||||
|
||||
test("failure while registering shutdown closes the constructed host", async () => {
|
||||
let shutdown = 0;
|
||||
const errors = [];
|
||||
const service = startDevelopmentPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => ({
|
||||
manager: {
|
||||
async initialize() {},
|
||||
async shutdown() { shutdown += 1; },
|
||||
},
|
||||
}),
|
||||
registerShutdown() { throw new Error("shutdown registry unavailable"); },
|
||||
logger: { error(...args) { errors.push(args); } },
|
||||
});
|
||||
assert.equal(service, null);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(shutdown, 1);
|
||||
assert.match(errors[0][1].message, /shutdown registry unavailable/);
|
||||
});
|
||||
316
electron/plugins/hostRpcRegistry.cjs
Normal file
316
electron/plugins/hostRpcRegistry.cjs
Normal file
@@ -0,0 +1,316 @@
|
||||
"use strict";
|
||||
|
||||
const RESERVED_METHODS = new Set([
|
||||
"plugin.initialize",
|
||||
"plugin.activate",
|
||||
"plugin.deactivate",
|
||||
"$/cancelRequest",
|
||||
"$/progress",
|
||||
]);
|
||||
|
||||
function assertHostMethod(method) {
|
||||
if (
|
||||
typeof method !== "string"
|
||||
|| method.length < 1
|
||||
|| method.length > 256
|
||||
|| method.startsWith("$/")
|
||||
|| RESERVED_METHODS.has(method)
|
||||
) {
|
||||
throw new TypeError(`Invalid or reserved plugin host RPC method: ${String(method)}`);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
function assertHandler(handler, label) {
|
||||
if (typeof handler !== "function") throw new TypeError(`${label} must be a function`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function cloneAndFreezeMetadata(value, seen = new Set()) {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("Plugin host RPC metadata numbers must be finite");
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || seen.has(value)) {
|
||||
throw new TypeError("Plugin host RPC metadata must be an acyclic JSON-like value");
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) {
|
||||
throw new TypeError("Plugin host RPC metadata must use plain objects and arrays");
|
||||
}
|
||||
seen.add(value);
|
||||
let clone;
|
||||
if (Array.isArray(value)) {
|
||||
clone = value.map((item) => cloneAndFreezeMetadata(item, seen));
|
||||
} else {
|
||||
clone = {};
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key !== "string") throw new TypeError("Plugin host RPC metadata keys must be strings");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin host RPC metadata must contain enumerable data properties");
|
||||
}
|
||||
Object.defineProperty(clone, key, {
|
||||
value: cloneAndFreezeMetadata(descriptor.value, seen),
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
seen.delete(value);
|
||||
return Object.freeze(clone);
|
||||
}
|
||||
|
||||
function normalizeRuntimeIdentity(identity) {
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) {
|
||||
throw new TypeError("Plugin runtime identity must be an object");
|
||||
}
|
||||
for (const key of ["pluginId", "pluginVersion", "runtimeId", "runtimeKind"]) {
|
||||
if (typeof identity[key] !== "string" || identity[key].length < 1) {
|
||||
throw new TypeError(`Plugin runtime identity ${key} is required`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
|
||||
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
|
||||
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
|
||||
...(identity.logger === undefined ? {} : { logger: identity.logger }),
|
||||
});
|
||||
}
|
||||
|
||||
function createDisposable(dispose) {
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class PluginHostRpcRegistry {
|
||||
constructor() {
|
||||
this.requests = new Map();
|
||||
this.notifications = new Map();
|
||||
this.middleware = new Set();
|
||||
this.incomingStreams = new Set();
|
||||
this.revision = 0;
|
||||
}
|
||||
|
||||
#register(collection, kind, method, handler, options = {}) {
|
||||
const normalizedMethod = assertHostMethod(method);
|
||||
assertHandler(handler, `Plugin host ${kind} handler`);
|
||||
if (options.validateParams != null) {
|
||||
assertHandler(options.validateParams, `Plugin host ${kind} parameter validator`);
|
||||
}
|
||||
if (typeof options.authorization === "function") {
|
||||
assertHandler(options.authorization, `Plugin host ${kind} authorization resolver`);
|
||||
}
|
||||
if (this.requests.has(normalizedMethod) || this.notifications.has(normalizedMethod)) {
|
||||
throw new Error(`Plugin host RPC method is already registered: ${normalizedMethod}`);
|
||||
}
|
||||
const registration = Object.freeze({
|
||||
handler,
|
||||
metadata: cloneAndFreezeMetadata(options.metadata ?? {}),
|
||||
validateParams: options.validateParams ?? null,
|
||||
authorization: typeof options.authorization === "function"
|
||||
? options.authorization
|
||||
: options.authorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(options.authorization),
|
||||
});
|
||||
collection.set(normalizedMethod, registration);
|
||||
this.revision += 1;
|
||||
return createDisposable(() => {
|
||||
if (collection.get(normalizedMethod) !== registration) return;
|
||||
collection.delete(normalizedMethod);
|
||||
this.revision += 1;
|
||||
});
|
||||
}
|
||||
|
||||
registerRequest(method, handler, options) {
|
||||
return this.#register(this.requests, "request", method, handler, options);
|
||||
}
|
||||
|
||||
registerNotification(method, handler, options) {
|
||||
return this.#register(this.notifications, "notification", method, handler, options);
|
||||
}
|
||||
|
||||
use(middleware) {
|
||||
assertHandler(middleware, "Plugin host RPC middleware");
|
||||
this.middleware.add(middleware);
|
||||
this.revision += 1;
|
||||
return createDisposable(() => {
|
||||
if (!this.middleware.delete(middleware)) return;
|
||||
this.revision += 1;
|
||||
});
|
||||
}
|
||||
|
||||
registerIncomingStream(handler) {
|
||||
assertHandler(handler, "Plugin incoming stream handler");
|
||||
this.incomingStreams.add(handler);
|
||||
this.revision += 1;
|
||||
return createDisposable(() => {
|
||||
if (!this.incomingStreams.delete(handler)) return;
|
||||
this.revision += 1;
|
||||
});
|
||||
}
|
||||
|
||||
createRoutes(runtimeIdentity) {
|
||||
const assertCurrent = runtimeIdentity?.assertCurrent;
|
||||
if (assertCurrent != null) assertHandler(assertCurrent, "Plugin runtime identity guard");
|
||||
const identity = normalizeRuntimeIdentity(runtimeIdentity);
|
||||
const middleware = [...this.middleware];
|
||||
const streamHandlers = [...this.incomingStreams];
|
||||
const createHandler = (kind, method, registration) => async (params, transportContext) => {
|
||||
const assertActive = async () => {
|
||||
transportContext.signal?.throwIfAborted();
|
||||
await assertCurrent?.();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
};
|
||||
await assertActive();
|
||||
const validatedParams = registration.validateParams
|
||||
? registration.validateParams(params)
|
||||
: params;
|
||||
if (validatedParams && typeof validatedParams.then === "function") {
|
||||
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
|
||||
}
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
metadata: registration.metadata,
|
||||
params: validatedParams,
|
||||
});
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
const authorization = resolvedAuthorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(resolvedAuthorization);
|
||||
await assertActive();
|
||||
const context = Object.freeze({
|
||||
...authorizationContext,
|
||||
authorization,
|
||||
});
|
||||
let index = -1;
|
||||
const dispatch = async (nextIndex) => {
|
||||
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
|
||||
index = nextIndex;
|
||||
const current = middleware[nextIndex];
|
||||
if (current) return current(context, () => dispatch(nextIndex + 1));
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
return result;
|
||||
};
|
||||
const requestHandlers = Object.freeze(Object.fromEntries(
|
||||
[...this.requests].map(([method, registration]) => [
|
||||
method,
|
||||
createHandler("request", method, registration),
|
||||
]),
|
||||
));
|
||||
const notificationHandlers = Object.freeze(Object.fromEntries(
|
||||
[...this.notifications].map(([method, registration]) => [
|
||||
method,
|
||||
createHandler("notification", method, registration),
|
||||
]),
|
||||
));
|
||||
const onIncomingStream = async (stream) => {
|
||||
stream.signal?.throwIfAborted();
|
||||
await assertCurrent?.();
|
||||
const context = Object.freeze({
|
||||
...identity,
|
||||
signal: stream.signal,
|
||||
assertActive: async () => {
|
||||
stream.signal?.throwIfAborted();
|
||||
await assertCurrent?.();
|
||||
stream.signal?.throwIfAborted();
|
||||
},
|
||||
kind: "stream",
|
||||
});
|
||||
for (const handler of streamHandlers) {
|
||||
const accepted = await handler(stream, context);
|
||||
if (accepted === true) {
|
||||
await context.assertActive();
|
||||
return true;
|
||||
}
|
||||
if (accepted !== false && accepted !== undefined) {
|
||||
throw new TypeError("Plugin incoming stream handler must return true, false, or undefined");
|
||||
}
|
||||
}
|
||||
await context.assertActive();
|
||||
return false;
|
||||
};
|
||||
return Object.freeze({
|
||||
notificationHandlers,
|
||||
onIncomingStream,
|
||||
requestHandlers,
|
||||
revision: this.revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultPluginHostRpcRegistry(options) {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("storage.get", async ({ key }, context) => {
|
||||
const value = options.database.getValue(context.pluginId, key);
|
||||
return value === undefined ? { found: false } : { found: true, value };
|
||||
}, {
|
||||
metadata: { capability: "storage", mutating: false, permission: "storage" },
|
||||
authorization: { permission: "storage", resources: ["*"], reason: "Access plugin storage" },
|
||||
validateParams: (params) => options.assertStorageParams(params),
|
||||
});
|
||||
registry.registerRequest("storage.set", async ({ key, value }, context) => {
|
||||
options.database.setValue(context.pluginId, key, value);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "storage", mutating: true, permission: "storage" },
|
||||
authorization: { permission: "storage", resources: ["*"], reason: "Modify plugin storage" },
|
||||
validateParams: (params) => options.assertStorageParams(params, { value: true }),
|
||||
});
|
||||
registry.registerRequest("storage.delete", async ({ key }, context) => {
|
||||
options.database.deleteValue(context.pluginId, key);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "storage", mutating: true, permission: "storage" },
|
||||
authorization: { permission: "storage", resources: ["*"], reason: "Modify plugin storage" },
|
||||
validateParams: (params) => options.assertStorageParams(params),
|
||||
});
|
||||
registry.registerRequest("storage.keys", async (params, context) => {
|
||||
return { keys: options.database.listKeys(context.pluginId) };
|
||||
}, {
|
||||
metadata: { capability: "storage", mutating: false, permission: "storage" },
|
||||
authorization: { permission: "storage", resources: ["*"], reason: "List plugin storage keys" },
|
||||
validateParams: (params) => {
|
||||
if (params && (typeof params !== "object" || Array.isArray(params) || Object.keys(params).length > 0)) {
|
||||
throw new TypeError("storage.keys does not accept parameters");
|
||||
}
|
||||
return params;
|
||||
},
|
||||
});
|
||||
registry.registerNotification("log.write", async (params, context) => {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return;
|
||||
await context.logger.write(params.level, params.message, params.fields);
|
||||
}, { metadata: { capability: "logging", mutating: false, public: true } });
|
||||
return registry;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PluginHostRpcRegistry,
|
||||
assertHostMethod,
|
||||
cloneAndFreezeMetadata,
|
||||
createDefaultPluginHostRpcRegistry,
|
||||
normalizeRuntimeIdentity,
|
||||
};
|
||||
309
electron/plugins/hostRpcRegistry.test.cjs
Normal file
309
electron/plugins/hostRpcRegistry.test.cjs
Normal file
@@ -0,0 +1,309 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PluginHostRpcRegistry,
|
||||
assertHostMethod,
|
||||
cloneAndFreezeMetadata,
|
||||
} = require("./hostRpcRegistry.cjs");
|
||||
|
||||
function identity(overrides = {}) {
|
||||
return {
|
||||
pluginId: "com.example.registry",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("host RPC registry separates requests from notifications and rejects duplicate ownership", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("settings.get", async (params, context) => ({
|
||||
params,
|
||||
kind: context.kind,
|
||||
pluginId: context.pluginId,
|
||||
}));
|
||||
assert.throws(
|
||||
() => registry.registerNotification("settings.get", () => {}),
|
||||
/already registered/,
|
||||
);
|
||||
registry.registerNotification("telemetry.write", () => undefined);
|
||||
const routes = registry.createRoutes(identity());
|
||||
assert.deepEqual(
|
||||
await routes.requestHandlers["settings.get"]({ key: "theme" }, { signal: AbortSignal.timeout(10) }),
|
||||
{ params: { key: "theme" }, kind: "request", pluginId: "com.example.registry" },
|
||||
);
|
||||
assert.equal(routes.notificationHandlers["settings.get"], undefined);
|
||||
assert.equal(routes.requestHandlers["telemetry.write"], undefined);
|
||||
});
|
||||
|
||||
test("host RPC middleware receives immutable host identity, method metadata, and transport context", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
const metadata = { permission: { name: "vault.metadata", resources: ["hosts"] } };
|
||||
registry.use(async (context, next) => {
|
||||
seen.push(context);
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("vault.read", async (_params, context) => context.runtimeId, {
|
||||
metadata,
|
||||
});
|
||||
metadata.permission.name = "spoofed";
|
||||
metadata.permission.resources.push("credentials");
|
||||
const routes = registry.createRoutes(identity());
|
||||
const signal = AbortSignal.timeout(100);
|
||||
assert.equal(await routes.requestHandlers["vault.read"](
|
||||
{ hostId: "host-1" },
|
||||
{
|
||||
cancellationId: "cancel-1",
|
||||
deadlineMs: 100,
|
||||
pluginId: "com.attacker.spoofed",
|
||||
requestId: 7,
|
||||
runtimeId: "spoofed-runtime",
|
||||
signal,
|
||||
},
|
||||
), "runtime-1");
|
||||
assert.equal(Object.isFrozen(seen[0]), true);
|
||||
assert.equal(seen[0].pluginVersion, "1.0.0");
|
||||
assert.equal(seen[0].pluginId, "com.example.registry");
|
||||
assert.equal(seen[0].runtimeId, "runtime-1");
|
||||
assert.equal(seen[0].method, "vault.read");
|
||||
assert.deepEqual(seen[0].metadata.permission, { name: "vault.metadata", resources: ["hosts"] });
|
||||
assert.equal(Object.isFrozen(seen[0].metadata.permission), true);
|
||||
assert.equal(Object.isFrozen(seen[0].metadata.permission.resources), true);
|
||||
assert.deepEqual(seen[0].params, { hostId: "host-1" });
|
||||
assert.equal(seen[0].requestId, 7);
|
||||
assert.equal(seen[0].signal, signal);
|
||||
assert.equal(typeof seen[0].assertActive, "function");
|
||||
});
|
||||
|
||||
test("method parameters are validated and normalized before capability middleware", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
registry.use(async (context, next) => {
|
||||
seen.push(context.params);
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("filesystem.stat", async (params) => params, {
|
||||
validateParams(params) {
|
||||
if (typeof params?.path !== "string") throw new TypeError("path is required");
|
||||
return { path: params.path.normalize("NFC") };
|
||||
},
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
const normalized = await routes.requestHandlers["filesystem.stat"](
|
||||
{ path: "cafe\u0301" },
|
||||
{ signal: AbortSignal.timeout(100) },
|
||||
);
|
||||
assert.deepEqual(normalized, { path: "caf\u00e9" });
|
||||
assert.deepEqual(seen, [{ path: "caf\u00e9" }]);
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["filesystem.stat"]({}, { signal: AbortSignal.timeout(100) }),
|
||||
/path is required/,
|
||||
);
|
||||
assert.equal(seen.length, 1);
|
||||
});
|
||||
|
||||
test("parameter validators must be synchronous and metadata cloning cannot invoke __proto__", async () => {
|
||||
const cloned = cloneAndFreezeMetadata(JSON.parse('{"__proto__":{"polluted":true}}'));
|
||||
assert.equal(Object.getPrototypeOf(cloned), Object.prototype);
|
||||
assert.equal(Object.hasOwn(cloned, "__proto__"), true);
|
||||
assert.equal(cloned.__proto__.polluted, true);
|
||||
assert.equal({}.polluted, undefined);
|
||||
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("network.fetch", () => null, {
|
||||
validateParams: async (params) => params,
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["network.fetch"]({}, { signal: AbortSignal.timeout(100) }),
|
||||
/must be synchronous/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host RPC identity is rechecked after asynchronous middleware and handler work", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releaseMiddleware;
|
||||
let handlerCalls = 0;
|
||||
registry.use(async (_context, next) => {
|
||||
await new Promise((resolve) => { releaseMiddleware = resolve; });
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("vault.write", async () => {
|
||||
handlerCalls += 1;
|
||||
return null;
|
||||
});
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["vault.write"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releaseMiddleware();
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
assert.equal(handlerCalls, 0);
|
||||
});
|
||||
|
||||
test("host RPC results are discarded when identity becomes stale during handler work", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releaseHandler;
|
||||
registry.registerRequest("vault.read", async () => new Promise((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
}));
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["vault.read"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releaseHandler({ secret: "stale" });
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
});
|
||||
|
||||
test("async capability handlers can recheck activation immediately before committing", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releasePreparation;
|
||||
let commits = 0;
|
||||
registry.registerRequest("settings.set", async (_params, context) => {
|
||||
await new Promise((resolve) => { releasePreparation = resolve; });
|
||||
await context.assertActive();
|
||||
commits += 1;
|
||||
return null;
|
||||
});
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["settings.set"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releasePreparation();
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
assert.equal(commits, 0);
|
||||
});
|
||||
|
||||
test("capability commit guards reject requests cancelled while handler work is pending", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseWork;
|
||||
const work = new Promise((resolve) => { releaseWork = resolve; });
|
||||
let committed = false;
|
||||
registry.registerRequest("filesystem.write", async (_params, context) => {
|
||||
await work;
|
||||
await context.assertActive();
|
||||
committed = true;
|
||||
return null;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: "com.example.cancelled",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-cancelled",
|
||||
runtimeKind: "utility",
|
||||
});
|
||||
const pending = routes.requestHandlers["filesystem.write"]({}, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("request cancelled"));
|
||||
releaseWork();
|
||||
await assert.rejects(pending, /request cancelled/);
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("cancelled middleware cannot enter a privileged capability handler", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseMiddleware;
|
||||
const middlewareWork = new Promise((resolve) => { releaseMiddleware = resolve; });
|
||||
let invoked = false;
|
||||
registry.use(async (_context, next) => {
|
||||
await middlewareWork;
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("network.fetch", async () => {
|
||||
invoked = true;
|
||||
return null;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: "com.example.cancelled",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-cancelled",
|
||||
runtimeKind: "utility",
|
||||
});
|
||||
const pending = routes.requestHandlers["network.fetch"]({}, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("approval cancelled"));
|
||||
releaseMiddleware();
|
||||
await assert.rejects(pending, /approval cancelled/);
|
||||
assert.equal(invoked, false);
|
||||
});
|
||||
|
||||
test("host RPC route snapshots remain stable while later registrations apply after restart", () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("first.call", () => null);
|
||||
const first = registry.createRoutes(identity());
|
||||
registry.registerRequest("second.call", () => null);
|
||||
const second = registry.createRoutes(identity({ runtimeId: "runtime-2" }));
|
||||
assert.equal(first.requestHandlers["second.call"], undefined);
|
||||
assert.equal(typeof second.requestHandlers["second.call"], "function");
|
||||
assert.ok(second.revision > first.revision);
|
||||
});
|
||||
|
||||
test("incoming streams route to the first owner and expose a host-assigned runtime identity", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
registry.registerIncomingStream(async (stream, context) => {
|
||||
seen.push([stream.streamId, context.runtimeId]);
|
||||
return false;
|
||||
});
|
||||
registry.registerIncomingStream(async (stream) => stream.streamId === "owned");
|
||||
const routes = registry.createRoutes(identity());
|
||||
assert.equal(await routes.onIncomingStream({ streamId: "owned" }), true);
|
||||
assert.equal(await routes.onIncomingStream({ streamId: "unknown" }), false);
|
||||
assert.deepEqual(seen, [["owned", "runtime-1"], ["unknown", "runtime-1"]]);
|
||||
});
|
||||
|
||||
test("incoming stream owners receive cancellation through the shared active guard", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseOwner;
|
||||
const ownerWork = new Promise((resolve) => { releaseOwner = resolve; });
|
||||
let committed = false;
|
||||
registry.registerIncomingStream(async (_stream, context) => {
|
||||
await ownerWork;
|
||||
await context.assertActive();
|
||||
committed = true;
|
||||
return true;
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
const controller = new AbortController();
|
||||
const pending = routes.onIncomingStream({
|
||||
streamId: "cancelled",
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("stream owner cancelled"));
|
||||
releaseOwner();
|
||||
|
||||
await assert.rejects(pending, /stream owner cancelled/);
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("reserved lifecycle and transport methods cannot be claimed as host capabilities", () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
for (const method of ["plugin.initialize", "plugin.activate", "plugin.deactivate", "$/progress"]) {
|
||||
assert.throws(() => assertHostMethod(method), /reserved/);
|
||||
assert.throws(() => registry.registerRequest(method, () => null), /reserved/);
|
||||
}
|
||||
assert.equal(assertHostMethod("commands.execute"), "commands.execute");
|
||||
});
|
||||
397
electron/plugins/hostService.cjs
Normal file
397
electron/plugins/hostService.cjs
Normal file
@@ -0,0 +1,397 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_API_VERSION } = require("./constants.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
|
||||
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
|
||||
const { PluginCredentialCatalog } = require("./credentialCatalog.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginContributionIconService } = require("./contributionIconService.cjs");
|
||||
const { PluginFilesystemBroker } = require("./filesystemBroker.cjs");
|
||||
const { createDefaultPluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const {
|
||||
createDefaultPluginModuleResources,
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
const {
|
||||
RuntimeSupervisor,
|
||||
assertStorageParams,
|
||||
resolveDefaultRuntimeKind,
|
||||
} = require("./runtimeSupervisor.cjs");
|
||||
const { PluginQuotaManager } = require("./quotaManager.cjs");
|
||||
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
|
||||
const { PluginSecretStore } = require("./secretStore.cjs");
|
||||
const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
const { PluginTerminalProviderService } = require("./terminalProviderService.cjs");
|
||||
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
|
||||
const { PluginExtensionProviderService } = require("./extensionProviderService.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
|
||||
function getElectronProcessMetrics(app, pid) {
|
||||
const metric = app.getAppMetrics?.().find((candidate) => candidate.pid === pid);
|
||||
if (!metric) return null;
|
||||
return {
|
||||
cpuPercent: Number(metric.cpu?.percentCPUUsage ?? 0),
|
||||
memoryBytes: Number(metric.memory?.workingSetSize ?? 0) * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full installed-manifest catalog of sync providers (active + inactive versions).
|
||||
* Shared by startup and enable-time backfill so ambiguity checks always see
|
||||
* cross-plugin / nested-namespace claims.
|
||||
* @param {{ listInstalledVersions?: () => Array<{ pluginId?: string, manifest?: { contributes?: { providers?: Array<{ kind?: string, id?: string }> } } }> }} database
|
||||
* @returns {Array<{ pluginId: string, provider: { id: string } }>}
|
||||
*/
|
||||
function collectInstalledSyncProviderCatalog(database) {
|
||||
const installedSyncProviders = [];
|
||||
const seen = new Set();
|
||||
const versions = typeof database.listInstalledVersions === "function"
|
||||
? database.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !version.manifest) continue;
|
||||
for (const provider of version.manifest.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const key = `${pluginId}\0${provider.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
installedSyncProviders.push({
|
||||
pluginId,
|
||||
provider: { id: provider.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
return installedSyncProviders;
|
||||
}
|
||||
|
||||
function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const database = new PluginDatabase(paths.database);
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
paths,
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: options.supportedFeatures ?? [],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
packageStore,
|
||||
BrowserWindow: options.electron.BrowserWindow,
|
||||
rasterizeIcon: options.rasterizeContributionIcon,
|
||||
});
|
||||
const moduleResources = options.moduleResources
|
||||
? normalizePluginModuleResources(options.moduleResources)
|
||||
: createDefaultPluginModuleResources(appRoot, options.additionalModuleResources ?? []);
|
||||
const protocol = new PluginProtocol({
|
||||
runtimeDirectory,
|
||||
moduleResources,
|
||||
});
|
||||
const permissionEngine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: options.requestPermissionDecision,
|
||||
});
|
||||
const quotaManager = new PluginQuotaManager({
|
||||
getProcessMetrics: options.getProcessMetrics
|
||||
?? ((pid) => getElectronProcessMetrics(options.app, pid)),
|
||||
quotas: options.quotas,
|
||||
});
|
||||
const secretStore = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: options.safeStorage ?? options.electron.safeStorage,
|
||||
});
|
||||
const leaseStore = new SecretLeaseStore({ secretStore });
|
||||
const credentialResolver = options.credentialResolver ?? new PluginCredentialCatalog({
|
||||
safeStorage: options.safeStorage ?? options.electron.safeStorage,
|
||||
});
|
||||
const credentialBroker = new PluginCredentialBroker({
|
||||
secretStore,
|
||||
leaseStore,
|
||||
credentialResolver,
|
||||
});
|
||||
const filesystemBroker = new PluginFilesystemBroker({
|
||||
quotaManager,
|
||||
openDirectoryHandle: options.openPluginDirectoryHandle,
|
||||
});
|
||||
const networkBroker = new PluginNetworkBroker({
|
||||
fetch: options.fetch ?? (options.electron.net?.fetch
|
||||
? (...args) => options.electron.net.fetch(...args)
|
||||
: undefined),
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
});
|
||||
let runtimeSupervisor;
|
||||
const companionSupervisor = new PluginCompanionSupervisor({
|
||||
paths,
|
||||
quotaManager,
|
||||
spawn: options.spawnCompanion,
|
||||
onContainmentFailure: (identity, error) => (
|
||||
runtimeSupervisor?.enforcePolicyViolation(identity, error)
|
||||
),
|
||||
});
|
||||
const rpcRegistry = options.rpcRegistry ?? createDefaultPluginHostRpcRegistry({
|
||||
assertStorageParams,
|
||||
database,
|
||||
});
|
||||
const runtimeAccess = Object.freeze({
|
||||
start: (...args) => runtimeSupervisor.start(...args),
|
||||
request: (...args) => runtimeSupervisor.request(...args),
|
||||
notify: (...args) => runtimeSupervisor.notify(...args),
|
||||
getRuntimeIdentity: (...args) => runtimeSupervisor.getRuntimeIdentity(...args),
|
||||
});
|
||||
const contributionService = new PluginContributionService({
|
||||
database,
|
||||
runtimeSupervisor: runtimeAccess,
|
||||
secretStore,
|
||||
getLocale: options.getLocale,
|
||||
});
|
||||
contributionService.registerRpcCapabilities(rpcRegistry);
|
||||
registerSecurePluginCapabilities(rpcRegistry, {
|
||||
assertLeaseParams,
|
||||
companionSupervisor,
|
||||
credentialBroker,
|
||||
filesystemBroker,
|
||||
networkBroker,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
secretStore,
|
||||
});
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
if (configuredRegistry && typeof configuredRegistry.then === "function") {
|
||||
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
|
||||
}
|
||||
const requestedRuntimeResolver = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
|
||||
const resolveRuntimeKind = async (context) => {
|
||||
const kind = await requestedRuntimeResolver(context);
|
||||
if (
|
||||
(kind !== "browser" && kind !== "utility")
|
||||
|| !context.availableKinds.includes(kind)
|
||||
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
});
|
||||
if (kind === "utility") {
|
||||
await permissionEngine.authorize({
|
||||
pluginId: context.plugin.id,
|
||||
pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null,
|
||||
manifest: context.plugin.manifest,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
});
|
||||
}
|
||||
return kind;
|
||||
};
|
||||
const runtimeMessageGuard = (identity, message) => {
|
||||
quotaManager.guardMessage(identity, message);
|
||||
return options.runtimeMessageGuard?.(identity, message);
|
||||
};
|
||||
runtimeSupervisor = new RuntimeSupervisor({
|
||||
electron: options.electron,
|
||||
database,
|
||||
packageStore,
|
||||
protocol,
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: options.supportedFeatures ?? [],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
resolveRuntimeKind,
|
||||
resolveSecurityPrincipal: options.resolveSecurityPrincipal,
|
||||
runtimeMessageGuard,
|
||||
getInitialEnvironment: () => contributionService.getEnvironment(),
|
||||
runtimeResourceMonitor: quotaManager,
|
||||
runtimeCleanup: async (identity) => {
|
||||
leaseStore.revokeRuntime(identity.runtimeId);
|
||||
await companionSupervisor.releaseRuntime(identity.runtimeId);
|
||||
},
|
||||
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
|
||||
});
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
runtimeSupervisor,
|
||||
});
|
||||
const extensionProviderService = new PluginExtensionProviderService({
|
||||
contributionService,
|
||||
leaseStore,
|
||||
permissionEngine,
|
||||
rpcRegistry,
|
||||
runtimeSupervisor,
|
||||
});
|
||||
const syncSidecarService = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService,
|
||||
});
|
||||
// Replay retained cloud sidecars into plugin_settings before the plugin
|
||||
// runtime starts so onStartupFinished sees hydrated values.
|
||||
const originalOnPluginEnabled = contributionService.onPluginEnabled.bind(contributionService);
|
||||
contributionService.onPluginEnabled = async (pluginId) => {
|
||||
try {
|
||||
await syncSidecarService.hydrateInstalledPluginSettings(pluginId);
|
||||
} catch {
|
||||
// Hydration is best-effort; enable must still succeed.
|
||||
}
|
||||
const enabled = await originalOnPluginEnabled(pluginId);
|
||||
try {
|
||||
// Use the same installed-manifest catalog as startup so cross-plugin /
|
||||
// inactive-version ambiguity checks still apply when only this plugin
|
||||
// was just enabled (Codex P2 on d3a9b94c).
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; enable must still succeed.
|
||||
}
|
||||
return enabled;
|
||||
};
|
||||
// Live-provider seed must wait until PackageStore.recover() has restored
|
||||
// missing plugin_versions. Construction-time seed can bind a stale owner
|
||||
// against an incomplete catalog; post-recovery seed then skips it as
|
||||
// existing (Codex P2 on f2b1b5d8). syncDeleteSecrets already awaits
|
||||
// resolveManager() → package initialize, so cleanup sees the post-recovery
|
||||
// seed without racing an early incomplete bind.
|
||||
const seedSyncBindings = () => {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders !== "function") return;
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
};
|
||||
// PackageStore.recover() repopulates plugin_versions; only then promote
|
||||
// legacy maps and seed live bindings (Codex P2 on dd7aad70 / f2b1b5d8).
|
||||
const originalPackageInitialize = packageStore.initialize.bind(packageStore);
|
||||
packageStore.initialize = async () => {
|
||||
const result = await originalPackageInitialize();
|
||||
try {
|
||||
if (typeof database.backfillSyncProviderBindingsFromLegacySecrets === "function") {
|
||||
database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
}
|
||||
seedSyncBindings();
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// Startup activation goes through contributionService.initialize() → #startPlugin
|
||||
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
|
||||
const originalInitialize = contributionService.initialize.bind(contributionService);
|
||||
contributionService.initialize = async () => {
|
||||
for (const plugin of database.listPlugins()) {
|
||||
if (!plugin?.enabled || typeof plugin.id !== "string") continue;
|
||||
try {
|
||||
await syncSidecarService.hydrateInstalledPluginSettings(plugin.id);
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
}
|
||||
// Re-seed after any package catalog changes during init.
|
||||
try {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
return originalInitialize();
|
||||
};
|
||||
const terminalDataPipelineService = options.electron.MessageChannelMain
|
||||
? new PluginTerminalDataPipelineService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
runtimeSupervisor,
|
||||
MessageChannelMain: options.electron.MessageChannelMain,
|
||||
requestSelection: options.requestTerminalInterceptorSelection,
|
||||
showWarning: options.showTerminalInterceptorWarning,
|
||||
})
|
||||
: null;
|
||||
quotaManager.setViolationHandler((identity, error) => (
|
||||
runtimeSupervisor.enforcePolicyViolation(identity, error)
|
||||
));
|
||||
const viewHost = options.electron.WebContentsView && options.electron.ipcMain
|
||||
? new PluginViewHost({
|
||||
electron: options.electron,
|
||||
protocol,
|
||||
packageStore,
|
||||
database,
|
||||
contributionService,
|
||||
})
|
||||
: null;
|
||||
const manager = new PluginManager({
|
||||
database,
|
||||
packageStore,
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
},
|
||||
});
|
||||
return {
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
credentialBroker,
|
||||
credentialResolver,
|
||||
database,
|
||||
filesystemBroker,
|
||||
extensionProviderService,
|
||||
syncSidecarService,
|
||||
leaseStore,
|
||||
manager,
|
||||
moduleResources,
|
||||
packageStore,
|
||||
paths,
|
||||
protocol,
|
||||
networkBroker,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
runtimeSupervisor,
|
||||
secretStore,
|
||||
terminalProviderService,
|
||||
terminalDataPipelineService,
|
||||
viewHost,
|
||||
};
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createPluginHostService, getElectronProcessMetrics };
|
||||
388
electron/plugins/hostService.test.cjs
Normal file
388
electron/plugins/hostService.test.cjs
Normal file
@@ -0,0 +1,388 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
|
||||
function createOptions(root, configureRpcRegistry) {
|
||||
return {
|
||||
app: {
|
||||
getAppPath: () => process.cwd(),
|
||||
getPath: () => root,
|
||||
getVersion: () => "0.0.0",
|
||||
},
|
||||
electron: {},
|
||||
configureRpcRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
function transportContext() {
|
||||
return {
|
||||
signal: new AbortController().signal,
|
||||
logger: { write: async () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test("host RPC registry configuration is complete before runtime initialization", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root, (registry) => {
|
||||
registry.registerRequest("custom.test", () => null);
|
||||
}));
|
||||
context.after(() => service.database.close());
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
});
|
||||
assert.equal(typeof routes.requestHandlers["custom.test"], "function");
|
||||
assert.equal(typeof service.terminalProviderService.provide, "function");
|
||||
});
|
||||
|
||||
test("async host RPC registry configuration fails before a service can start", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.throws(
|
||||
() => createPluginHostService(createOptions(root, async () => {})),
|
||||
/must be synchronous/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host service forwards the transport quota guard to the runtime supervisor", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let guarded;
|
||||
const runtimeMessageGuard = (identity, message) => { guarded = { identity, message }; };
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
runtimeMessageGuard,
|
||||
});
|
||||
context.after(() => service.database.close());
|
||||
const identity = { runtimeId: "runtime-1" };
|
||||
const message = { jsonrpc: "2.0" };
|
||||
service.runtimeSupervisor.runtimeMessageGuard(identity, message);
|
||||
assert.deepEqual(guarded, { identity, message });
|
||||
});
|
||||
|
||||
test("host service seeds the latest contribution environment before runtime activation", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root));
|
||||
context.after(() => service.manager.shutdown());
|
||||
const environment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: false };
|
||||
|
||||
await service.contributionService.setEnvironment(environment);
|
||||
|
||||
assert.deepEqual(service.runtimeSupervisor.getInitialEnvironment(), environment);
|
||||
});
|
||||
|
||||
test("runtime cleanup revokes leases before awaiting companion teardown", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root));
|
||||
context.after(() => service.manager.shutdown());
|
||||
const events = [];
|
||||
let releaseCompanions;
|
||||
service.leaseStore.revokeRuntime = (runtimeId) => {
|
||||
events.push(`revoke:${runtimeId}`);
|
||||
};
|
||||
service.companionSupervisor.releaseRuntime = async (runtimeId) => {
|
||||
events.push(`release:${runtimeId}`);
|
||||
await new Promise((resolve) => {
|
||||
releaseCompanions = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const cleanup = service.runtimeSupervisor.runtimeCleanup({ runtimeId: "runtime-1" })
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(events, ["revoke:runtime-1", "release:runtime-1"]);
|
||||
assert.equal(settled, false);
|
||||
releaseCompanions();
|
||||
await cleanup;
|
||||
assert.equal(settled, true);
|
||||
});
|
||||
|
||||
test("runtime trust placement precedes required and advanced permission prompts", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const events = [];
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
resolveRuntimeKind: async () => {
|
||||
events.push("placement");
|
||||
return "utility";
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
events.push(`permission:${request.permission}`);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
const manifest = {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.advanced",
|
||||
name: "advanced",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { node: "index.js" },
|
||||
permissions: { required: ["runtime.advanced", "storage"] },
|
||||
};
|
||||
assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: { id: manifest.id, activeVersion: manifest.version, manifest },
|
||||
availableKinds: ["utility"],
|
||||
securityPrincipal: "verified:test-publisher-key",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(events, [
|
||||
"placement",
|
||||
"permission:storage",
|
||||
"permission:runtime.advanced",
|
||||
]);
|
||||
});
|
||||
|
||||
test("first-party placement selects the utility runtime for companion manifests", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const requested = [];
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
requestPermissionDecision: async (request) => {
|
||||
requested.push(request.permission);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
const manifest = {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.companion-placement",
|
||||
name: "companion-placement",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "browser.js", node: "node.js" },
|
||||
permissions: {
|
||||
required: [
|
||||
"runtime.advanced",
|
||||
{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.companion-placement.helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.companion-placement.helper",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: [`${process.platform}-${process.arch}`],
|
||||
sha256: "0".repeat(64),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: { id: manifest.id, activeVersion: manifest.version, manifest },
|
||||
availableKinds: ["browser", "utility"],
|
||||
securityPrincipal: "unsigned-package:companion-placement",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(requested, ["companion.execute", "runtime.advanced"]);
|
||||
});
|
||||
|
||||
test("first-party placement selects utility for dual-entrypoint terminal interceptors", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const requested = [];
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
requestPermissionDecision: async (request) => {
|
||||
requested.push(request);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
const manifest = {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.interceptor-placement",
|
||||
name: "interceptor-placement",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "browser.js", node: "node.js" },
|
||||
permissions: {
|
||||
required: ["runtime.advanced", "provider.terminal", "terminal.intercept.input"],
|
||||
},
|
||||
contributes: {
|
||||
providers: [{
|
||||
id: "com.example.interceptor-placement.input",
|
||||
label: "Input filter",
|
||||
kind: "terminal.interceptor.input",
|
||||
}],
|
||||
},
|
||||
};
|
||||
assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: { id: manifest.id, activeVersion: manifest.version, manifest },
|
||||
availableKinds: ["browser", "utility"],
|
||||
securityPrincipal: "unsigned-package:interceptor-placement",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(requested.map((request) => request.permission), [
|
||||
"provider.terminal",
|
||||
"terminal.intercept.input",
|
||||
"runtime.advanced",
|
||||
]);
|
||||
assert.match(
|
||||
requested.find((request) => request.permission === "terminal.intercept.input")?.reason ?? "",
|
||||
/arbitrary no-echo input may not be detectable/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("secure host methods fail closed without an approver while public logging remains available", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root));
|
||||
context.after(() => service.manager.shutdown());
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: {
|
||||
id: "com.example.service",
|
||||
name: "service",
|
||||
publisher: "example",
|
||||
permissions: { required: ["storage"] },
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext()),
|
||||
/not granted/,
|
||||
);
|
||||
await routes.notificationHandlers["log.write"]({ level: "info", message: "hello" }, transportContext());
|
||||
assert.equal(service.database.getValue("com.example.service", "answer"), undefined);
|
||||
});
|
||||
|
||||
test("approved host capabilities reuse application grants through the registry", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let prompts = 0;
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
requestPermissionDecision: async (request) => {
|
||||
prompts += 1;
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
const pluginManifest = {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.service",
|
||||
name: "service",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "index.js" },
|
||||
permissions: { required: ["storage"] },
|
||||
};
|
||||
service.database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "com.example.service/1.0.0/package",
|
||||
});
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
await routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext());
|
||||
assert.deepEqual(
|
||||
await routes.requestHandlers["storage.get"]({ key: "answer" }, transportContext()),
|
||||
{ found: true, value: 42 },
|
||||
);
|
||||
assert.equal(prompts, 1);
|
||||
});
|
||||
|
||||
test("host service wires a handle-bound directory adapter without changing the RPC seam", async (context) => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const directoryPath = path.join(root, "enumerate");
|
||||
await fsp.mkdir(directoryPath);
|
||||
await fsp.writeFile(path.join(directoryPath, "entry.txt"), "entry");
|
||||
const service = createPluginHostService({
|
||||
...createOptions(root),
|
||||
requestPermissionDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "once",
|
||||
}),
|
||||
openPluginDirectoryHandle: async (directoryPath) => {
|
||||
const stats = await fsp.stat(directoryPath);
|
||||
const directory = await fsp.opendir(directoryPath);
|
||||
return {
|
||||
stat: async () => stats,
|
||||
read: () => directory.read(),
|
||||
close: async () => {
|
||||
try { await directory.close(); }
|
||||
catch (error) { if (error?.code !== "ERR_DIR_CLOSED") throw error; }
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
const pluginManifest = {
|
||||
id: "com.example.directory",
|
||||
name: "directory",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
permissions: { optional: [{ permission: "filesystem.read", resources: [directoryPath] }] },
|
||||
};
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await routes.requestHandlers["filesystem.readDirectory"](
|
||||
{ path: directoryPath },
|
||||
transportContext(),
|
||||
),
|
||||
{ entries: [{ name: "entry.txt", kind: "file" }] },
|
||||
);
|
||||
});
|
||||
|
||||
test("custom host methods without an explicit authorization classification are denied", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root, (registry) => {
|
||||
registry.registerRequest("custom.unclassified", () => ({ value: true }));
|
||||
}));
|
||||
context.after(() => service.manager.shutdown());
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: { permissions: { optional: ["settings.read"] } },
|
||||
});
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["custom.unclassified"]({}, transportContext()),
|
||||
/no authorization policy/,
|
||||
);
|
||||
});
|
||||
123
electron/plugins/jsonBoundary.cjs
Normal file
123
electron/plugins/jsonBoundary.cjs
Normal file
@@ -0,0 +1,123 @@
|
||||
"use strict";
|
||||
|
||||
const PLUGIN_JSON_MAX_DEPTH = 128;
|
||||
const PLUGIN_JSON_MAX_NODES = 100_000;
|
||||
|
||||
function jsonStringByteLength(value) {
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09
|
||||
|| code === 0x0a || code === 0x0c || code === 0x0d) {
|
||||
bytes += 2;
|
||||
} else if (code < 0x20) {
|
||||
bytes += 6;
|
||||
} else if (code < 0x80) {
|
||||
bytes += 1;
|
||||
} else if (code < 0x800) {
|
||||
bytes += 2;
|
||||
} else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 6;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
bytes += 6;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function assertPluginJsonValue(value, options = {}) {
|
||||
const maxBytes = options.maxBytes ?? Number.MAX_SAFE_INTEGER;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new TypeError("Plugin JSON maxBytes must be a positive safe integer");
|
||||
}
|
||||
const stack = [{ value, depth: 0 }];
|
||||
const ancestors = new Set();
|
||||
let nodes = 0;
|
||||
let bytes = 0;
|
||||
const addBytes = (count) => {
|
||||
bytes += count;
|
||||
if (bytes > maxBytes) throw new RangeError(`Plugin JSON exceeds ${maxBytes} bytes`);
|
||||
};
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current.exit) {
|
||||
ancestors.delete(current.value);
|
||||
continue;
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > PLUGIN_JSON_MAX_NODES) {
|
||||
throw new RangeError(`Plugin JSON exceeds ${PLUGIN_JSON_MAX_NODES} values`);
|
||||
}
|
||||
if (current.depth > PLUGIN_JSON_MAX_DEPTH) {
|
||||
throw new RangeError(`Plugin JSON exceeds ${PLUGIN_JSON_MAX_DEPTH} levels`);
|
||||
}
|
||||
const item = current.value;
|
||||
if (item === null) {
|
||||
addBytes(4);
|
||||
continue;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
addBytes(jsonStringByteLength(item));
|
||||
continue;
|
||||
}
|
||||
if (typeof item === "boolean") {
|
||||
addBytes(item ? 4 : 5);
|
||||
continue;
|
||||
}
|
||||
if (typeof item === "number") {
|
||||
if (!Number.isFinite(item)) throw new TypeError("Plugin JSON numbers must be finite");
|
||||
addBytes(Buffer.byteLength(String(item), "utf8"));
|
||||
continue;
|
||||
}
|
||||
if (typeof item !== "object") throw new TypeError("Plugin wire values must be JSON values");
|
||||
if (ancestors.has(item)) throw new TypeError("Plugin JSON values must not contain cycles");
|
||||
ancestors.add(item);
|
||||
stack.push({ value: item, depth: current.depth, exit: true });
|
||||
if (Array.isArray(item)) {
|
||||
addBytes(Math.max(2, item.length + 1));
|
||||
if (Object.keys(item).length !== item.length || Reflect.ownKeys(item).length !== item.length + 1) {
|
||||
throw new TypeError("Plugin JSON arrays must be dense and contain no named properties");
|
||||
}
|
||||
for (let index = item.length - 1; index >= 0; index -= 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin JSON arrays must contain enumerable data properties only");
|
||||
}
|
||||
stack.push({ value: descriptor.value, depth: current.depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(item);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("Plugin JSON objects must be plain objects");
|
||||
}
|
||||
const stringKeys = Object.keys(item);
|
||||
addBytes(Math.max(2, stringKeys.length + 1));
|
||||
if (Reflect.ownKeys(item).length !== stringKeys.length) {
|
||||
throw new TypeError("Plugin JSON objects must not contain symbols or non-enumerable properties");
|
||||
}
|
||||
for (let index = stringKeys.length - 1; index >= 0; index -= 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, stringKeys[index]);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("Plugin JSON objects must contain own data properties");
|
||||
}
|
||||
addBytes(jsonStringByteLength(stringKeys[index]) + 1);
|
||||
stack.push({ value: descriptor.value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
assertPluginJsonValue,
|
||||
};
|
||||
80
electron/plugins/jsonBoundary.test.cjs
Normal file
80
electron/plugins/jsonBoundary.test.cjs
Normal file
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
assertPluginJsonValue,
|
||||
} = require("./jsonBoundary.cjs");
|
||||
|
||||
test("main-process JSON boundary matches the public contract budgets", async () => {
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const hostLimits = require("./constants.cjs");
|
||||
assert.equal(PLUGIN_JSON_MAX_DEPTH, contract.PLUGIN_JSON_MAX_DEPTH);
|
||||
assert.equal(PLUGIN_JSON_MAX_NODES, contract.PLUGIN_JSON_MAX_NODES);
|
||||
assert.equal(hostLimits.PLUGIN_RPC_MAX_JSON_BYTES, contract.PLUGIN_RPC_MAX_JSON_BYTES);
|
||||
assert.equal(
|
||||
hostLimits.PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
contract.PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
test("main-process JSON boundary rejects deep, sparse, cyclic, and accessor values", () => {
|
||||
let deep = null;
|
||||
for (let index = 0; index <= PLUGIN_JSON_MAX_DEPTH; index += 1) deep = [deep];
|
||||
assert.throws(() => assertPluginJsonValue(deep), /levels/);
|
||||
const sparse = [];
|
||||
sparse.length = 1;
|
||||
assert.throws(() => assertPluginJsonValue(sparse), /dense/);
|
||||
const cyclic = {};
|
||||
cyclic.self = cyclic;
|
||||
assert.throws(() => assertPluginJsonValue(cyclic), /cycles/);
|
||||
const accessor = {};
|
||||
Object.defineProperty(accessor, "value", { get: () => 1, enumerable: true });
|
||||
assert.throws(() => assertPluginJsonValue(accessor), /data properties/);
|
||||
});
|
||||
|
||||
test("main-process JSON boundary enforces an incremental UTF-8 byte budget", () => {
|
||||
const value = { text: "你好\n\"", empty: [] };
|
||||
const exactBytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
assert.deepEqual(assertPluginJsonValue(value, { maxBytes: exactBytes }), value);
|
||||
assert.throws(
|
||||
() => assertPluginJsonValue(value, { maxBytes: exactBytes - 1 }),
|
||||
new RegExp(`exceeds ${exactBytes - 1} bytes`),
|
||||
);
|
||||
assert.throws(() => assertPluginJsonValue({}, { maxBytes: 0 }), /positive safe integer/);
|
||||
});
|
||||
|
||||
test("maximum broker raw payloads fit inside JSON-RPC envelopes", () => {
|
||||
const { PLUGIN_RPC_MAX_RAW_BYTES, PLUGIN_RPC_MAX_JSON_BYTES } = require("./constants.cjs");
|
||||
const { assertRpcMessage } = require("./contractValidator.cjs");
|
||||
const data = Buffer.alloc(PLUGIN_RPC_MAX_RAW_BYTES).toString("base64");
|
||||
const response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
url: `https://example.com/${"x".repeat(8_000)}`,
|
||||
status: 200,
|
||||
headers: { "x-budget": "y".repeat(64 * 1024) },
|
||||
body: { encoding: "base64", data },
|
||||
},
|
||||
};
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(response)) < PLUGIN_RPC_MAX_JSON_BYTES);
|
||||
assert.doesNotThrow(() => assertRpcMessage(response));
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "network.request",
|
||||
params: {
|
||||
url: `https://example.com/${"x".repeat(8_000)}`,
|
||||
headers: Object.fromEntries(Array.from({ length: 8 }, (_, index) => (
|
||||
[`x-budget-${index}`, "\\".repeat(8 * 1024)]
|
||||
))),
|
||||
body: { encoding: "utf8", data: "\0".repeat(PLUGIN_RPC_MAX_RAW_BYTES) },
|
||||
},
|
||||
};
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(request)) < PLUGIN_RPC_MAX_JSON_BYTES);
|
||||
assert.doesNotThrow(() => assertRpcMessage(request));
|
||||
});
|
||||
71
electron/plugins/keybindings.cjs
Normal file
71
electron/plugins/keybindings.cjs
Normal file
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
|
||||
const MODIFIERS = new Map([
|
||||
["alt", "Alt"],
|
||||
["cmd", "Command"],
|
||||
["cmdorctrl", "CommandOrControl"],
|
||||
["command", "Command"],
|
||||
["commandorcontrol", "CommandOrControl"],
|
||||
["control", "Control"],
|
||||
["ctrl", "Control"],
|
||||
["meta", "Command"],
|
||||
["mod", "CommandOrControl"],
|
||||
["option", "Alt"],
|
||||
["shift", "Shift"],
|
||||
]);
|
||||
const PRIMARY_MODIFIERS = new Set(["Command", "CommandOrControl", "Control"]);
|
||||
|
||||
const NAMED_KEYS = new Map([
|
||||
["arrowdown", "Down"],
|
||||
["arrowleft", "Left"],
|
||||
["arrowright", "Right"],
|
||||
["arrowup", "Up"],
|
||||
["backspace", "Backspace"],
|
||||
["delete", "Delete"],
|
||||
["down", "Down"],
|
||||
["end", "End"],
|
||||
["enter", "Enter"],
|
||||
["escape", "Esc"],
|
||||
["esc", "Esc"],
|
||||
["home", "Home"],
|
||||
["insert", "Insert"],
|
||||
["left", "Left"],
|
||||
["minus", "-"],
|
||||
["pagedown", "PageDown"],
|
||||
["pageup", "PageUp"],
|
||||
["plus", "+"],
|
||||
["right", "Right"],
|
||||
["space", "Space"],
|
||||
["tab", "Tab"],
|
||||
["up", "Up"],
|
||||
]);
|
||||
|
||||
function toElectronAccelerator(shortcut) {
|
||||
if (typeof shortcut !== "string" || shortcut.length < 1 || shortcut.length > 128) return undefined;
|
||||
const tokens = shortcut.split("+").map((token) => token.trim());
|
||||
if (tokens.some((token) => token.length === 0) || tokens.length > 5) return undefined;
|
||||
const modifiers = [];
|
||||
const seen = new Set();
|
||||
let key;
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
const modifier = MODIFIERS.get(lower);
|
||||
if (modifier) {
|
||||
if (key || seen.has(modifier)
|
||||
|| (PRIMARY_MODIFIERS.has(modifier) && [...seen].some((item) => PRIMARY_MODIFIERS.has(item)))) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(modifier);
|
||||
modifiers.push(modifier);
|
||||
continue;
|
||||
}
|
||||
if (key) return undefined;
|
||||
if (/^[a-z0-9]$/u.test(lower)) key = lower.toUpperCase();
|
||||
else if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(lower)) key = lower.toUpperCase();
|
||||
else key = NAMED_KEYS.get(lower);
|
||||
if (!key) return undefined;
|
||||
}
|
||||
return key ? [...modifiers, key].join("+") : undefined;
|
||||
}
|
||||
|
||||
module.exports = { toElectronAccelerator };
|
||||
20
electron/plugins/keybindings.test.cjs
Normal file
20
electron/plugins/keybindings.test.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { toElectronAccelerator } = require("./keybindings.cjs");
|
||||
|
||||
test("plugin keybindings convert to bounded Electron accelerators", () => {
|
||||
assert.equal(toElectronAccelerator("ctrl+shift+h"), "Control+Shift+H");
|
||||
assert.equal(toElectronAccelerator("cmd+arrowup"), "Command+Up");
|
||||
assert.equal(toElectronAccelerator("mod+enter"), "CommandOrControl+Enter");
|
||||
assert.equal(toElectronAccelerator("alt+f12"), "Alt+F12");
|
||||
assert.equal(toElectronAccelerator("ctrl+ctrl+h"), undefined);
|
||||
assert.equal(toElectronAccelerator("mod+ctrl+h"), undefined);
|
||||
assert.equal(toElectronAccelerator("ctrl+not-a-key"), undefined);
|
||||
assert.equal(toElectronAccelerator("ctrl+h+g"), undefined);
|
||||
assert.equal(toElectronAccelerator("ctrl++p"), undefined);
|
||||
assert.equal(toElectronAccelerator("+ctrl+p"), undefined);
|
||||
assert.equal(toElectronAccelerator("ctrl+p+"), undefined);
|
||||
});
|
||||
59
electron/plugins/moduleResources.cjs
Normal file
59
electron/plugins/moduleResources.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const MODULE_SPECIFIER_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
const MODULE_ENTRY_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$/;
|
||||
|
||||
function normalizePluginModuleResources(resources) {
|
||||
if (!Array.isArray(resources) || resources.length < 1 || resources.length > 32) {
|
||||
throw new TypeError("Plugin host module resources must contain between 1 and 32 entries");
|
||||
}
|
||||
const specifiers = new Set();
|
||||
return Object.freeze(resources.map((resource, index) => {
|
||||
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
||||
throw new TypeError("Plugin host module resource must be an object");
|
||||
}
|
||||
const { specifier, directory, entry = "index.js" } = resource;
|
||||
if (typeof specifier !== "string" || !MODULE_SPECIFIER_PATTERN.test(specifier)) {
|
||||
throw new TypeError(`Invalid plugin host module specifier: ${String(specifier)}`);
|
||||
}
|
||||
if (specifiers.has(specifier)) throw new Error(`Duplicate plugin host module specifier: ${specifier}`);
|
||||
specifiers.add(specifier);
|
||||
if (typeof directory !== "string" || !path.isAbsolute(directory)) {
|
||||
throw new TypeError(`Plugin host module directory must be absolute: ${specifier}`);
|
||||
}
|
||||
if (typeof entry !== "string" || !MODULE_ENTRY_PATTERN.test(entry) || entry.includes("\\")) {
|
||||
throw new TypeError(`Invalid plugin host module entry: ${specifier}`);
|
||||
}
|
||||
return Object.freeze({ directory, entry, route: `m${index}`, specifier });
|
||||
}));
|
||||
}
|
||||
|
||||
function createDefaultPluginModuleResources(appRoot, additional = []) {
|
||||
return normalizePluginModuleResources([
|
||||
{
|
||||
specifier: "@netcatty/plugin-sdk",
|
||||
directory: path.join(appRoot, "node_modules", "@netcatty", "plugin-sdk", "dist"),
|
||||
},
|
||||
{
|
||||
specifier: "@netcatty/plugin-contract",
|
||||
directory: path.join(appRoot, "node_modules", "@netcatty", "plugin-contract", "dist"),
|
||||
},
|
||||
...additional,
|
||||
]);
|
||||
}
|
||||
|
||||
function createUtilityModuleMappings(resources) {
|
||||
return Object.freeze(Object.fromEntries(resources.map((resource) => [
|
||||
resource.specifier,
|
||||
pathToFileURL(path.join(resource.directory, ...resource.entry.split("/"))).href,
|
||||
])));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDefaultPluginModuleResources,
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
};
|
||||
41
electron/plugins/moduleResources.test.cjs
Normal file
41
electron/plugins/moduleResources.test.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
|
||||
test("plugin host modules use deterministic private routes across browser and utility runtimes", () => {
|
||||
const resources = normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-sdk", directory: path.resolve("sdk") },
|
||||
{ specifier: "@netcatty/plugin-ui", directory: path.resolve("ui"), entry: "browser/index.js" },
|
||||
]);
|
||||
assert.deepEqual(resources.map(({ route }) => route), ["m0", "m1"]);
|
||||
const mappings = createUtilityModuleMappings(resources);
|
||||
assert.match(mappings["@netcatty/plugin-sdk"], /sdk\/index\.js$/);
|
||||
assert.match(mappings["@netcatty/plugin-ui"], /ui\/browser\/index\.js$/);
|
||||
assert.equal(Object.isFrozen(resources), true);
|
||||
assert.equal(Object.isFrozen(mappings), true);
|
||||
});
|
||||
|
||||
test("plugin host module resources reject aliases and unsafe entries", () => {
|
||||
assert.throws(() => normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-sdk", directory: path.resolve("sdk") },
|
||||
{ specifier: "@netcatty/plugin-sdk", directory: path.resolve("other") },
|
||||
]), /Duplicate/);
|
||||
assert.throws(() => normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-ui", directory: "relative" },
|
||||
]), /must be absolute/);
|
||||
assert.throws(() => normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-ui", directory: path.resolve("ui"), entry: "../escape.js" },
|
||||
]), /Invalid/);
|
||||
for (const entry of ["dir//index.js", "dir/", "dir\\index.js", ".hidden/index.js"]) {
|
||||
assert.throws(() => normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-ui", directory: path.resolve("ui"), entry },
|
||||
]), /Invalid/);
|
||||
}
|
||||
});
|
||||
97
electron/plugins/nativePermissionDecision.cjs
Normal file
97
electron/plugins/nativePermissionDecision.cjs
Normal file
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const MAX_VISIBLE_RESOURCES = 20;
|
||||
const MAX_VISIBLE_FIELD_LENGTH = 1_024;
|
||||
const UNSAFE_DISPLAY_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu;
|
||||
|
||||
function escapePermissionText(value, maxLength = MAX_VISIBLE_FIELD_LENGTH) {
|
||||
const text = String(value).slice(0, maxLength);
|
||||
return text.replace(UNSAFE_DISPLAY_CHARACTERS, (character) => {
|
||||
if (character === "\n") return "\\n";
|
||||
if (character === "\r") return "\\r";
|
||||
if (character === "\t") return "\\t";
|
||||
return `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`;
|
||||
});
|
||||
}
|
||||
|
||||
function describePermissionRequest(request) {
|
||||
const identity = request.pluginName
|
||||
? `${escapePermissionText(request.pluginName)} (${escapePermissionText(request.pluginId)})`
|
||||
: escapePermissionText(request.pluginId);
|
||||
const resources = request.resources ?? [];
|
||||
const resourceKinds = request.resourceKinds ?? [];
|
||||
const visibleResources = resources.slice(0, MAX_VISIBLE_RESOURCES).map((resource, index) => {
|
||||
const suffix = resourceKinds[index] === "directory" ? " (directory and descendants)" : "";
|
||||
return `- ${escapePermissionText(resource)}${suffix}`;
|
||||
});
|
||||
if (resources.length > visibleResources.length) {
|
||||
visibleResources.push(`- …and ${resources.length - visibleResources.length} more`);
|
||||
}
|
||||
return [
|
||||
`Plugin: ${identity}`,
|
||||
...(request.publisher ? [`Publisher: ${escapePermissionText(request.publisher)}`] : []),
|
||||
`Permission: ${escapePermissionText(request.permission)}`,
|
||||
`Reason: ${escapePermissionText(request.reason)}`,
|
||||
...(visibleResources.length ? ["Resources:", ...visibleResources] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createNativePermissionDecisionProvider(options) {
|
||||
if (!options?.dialog || typeof options.dialog.showMessageBox !== "function") {
|
||||
throw new TypeError("Native plugin permission decisions require an Electron dialog");
|
||||
}
|
||||
const dialog = options.dialog;
|
||||
const window = options.window;
|
||||
return async (request, context = {}) => {
|
||||
context.signal?.throwIfAborted();
|
||||
const allowedScopes = Array.isArray(request.allowedScopes)
|
||||
? new Set(request.allowedScopes)
|
||||
: null;
|
||||
const supportsScope = (scope) => allowedScopes == null || allowedScopes.has(scope);
|
||||
const choices = [
|
||||
{ label: "Deny", decision: "deny" },
|
||||
...(supportsScope("once") ? [{ label: "Allow Once", decision: "allow", scope: "once" }] : []),
|
||||
...(request.sessionId && supportsScope("session")
|
||||
? [{ label: "Allow for Session", decision: "allow", scope: "session" }]
|
||||
: []),
|
||||
...(supportsScope("application")
|
||||
? [{ label: "Allow for Application", decision: "allow", scope: "application" }]
|
||||
: []),
|
||||
...(supportsScope("always")
|
||||
? [{ label: "Always Allow", decision: "allow", scope: "always" }]
|
||||
: []),
|
||||
];
|
||||
const messageBoxOptions = {
|
||||
type: "warning",
|
||||
title: "Plugin permission request",
|
||||
message: `${escapePermissionText(request.pluginName ?? request.pluginId)} requests ${escapePermissionText(request.permission)}`,
|
||||
detail: describePermissionRequest(request),
|
||||
buttons: choices.map(({ label }) => label),
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
...(context.signal ? { signal: context.signal } : {}),
|
||||
};
|
||||
const result = window
|
||||
? await dialog.showMessageBox(window, messageBoxOptions)
|
||||
: await dialog.showMessageBox(messageBoxOptions);
|
||||
context.signal?.throwIfAborted();
|
||||
const choice = choices[result.response] ?? choices[0];
|
||||
if (choice.decision !== "allow") {
|
||||
return Object.freeze({ requestId: request.requestId, decision: "deny" });
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: choice.scope,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_VISIBLE_FIELD_LENGTH,
|
||||
MAX_VISIBLE_RESOURCES,
|
||||
createNativePermissionDecisionProvider,
|
||||
describePermissionRequest,
|
||||
escapePermissionText,
|
||||
};
|
||||
133
electron/plugins/nativePermissionDecision.test.cjs
Normal file
133
electron/plugins/nativePermissionDecision.test.cjs
Normal file
@@ -0,0 +1,133 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
MAX_VISIBLE_FIELD_LENGTH,
|
||||
MAX_VISIBLE_RESOURCES,
|
||||
createNativePermissionDecisionProvider,
|
||||
describePermissionRequest,
|
||||
escapePermissionText,
|
||||
} = require("./nativePermissionDecision.cjs");
|
||||
|
||||
function permissionRequest(overrides = {}) {
|
||||
return {
|
||||
requestId: "request-1",
|
||||
pluginId: "com.example.permissions",
|
||||
pluginName: "Permissions",
|
||||
publisher: "example",
|
||||
permission: "filesystem.read",
|
||||
resources: ["/allowed"],
|
||||
resourceKinds: ["directory"],
|
||||
reason: "Read project files",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("native permission decisions expose every host-owned grant scope", async () => {
|
||||
for (const { sessionId, response, expected } of [
|
||||
{ response: 0, expected: { requestId: "request-1", decision: "deny" } },
|
||||
{ response: 1, expected: { requestId: "request-1", decision: "allow", scope: "once" } },
|
||||
{ sessionId: "session-1", response: 2, expected: {
|
||||
requestId: "request-1", decision: "allow", scope: "session",
|
||||
} },
|
||||
{ response: 2, expected: {
|
||||
requestId: "request-1", decision: "allow", scope: "application",
|
||||
} },
|
||||
{ response: 3, expected: {
|
||||
requestId: "request-1", decision: "allow", scope: "always",
|
||||
} },
|
||||
]) {
|
||||
let captured;
|
||||
const signal = new AbortController().signal;
|
||||
const provider = createNativePermissionDecisionProvider({
|
||||
dialog: {
|
||||
async showMessageBox(_window, options) {
|
||||
captured = options;
|
||||
return { response };
|
||||
},
|
||||
},
|
||||
window: {},
|
||||
});
|
||||
assert.deepEqual(await provider(permissionRequest({ sessionId }), { signal }), expected);
|
||||
assert.equal(captured.signal, signal);
|
||||
assert.equal(captured.cancelId, 0);
|
||||
assert.match(captured.detail, /directory and descendants/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("native permission decisions fail closed on cancellation and abort", async () => {
|
||||
const provider = createNativePermissionDecisionProvider({
|
||||
dialog: { showMessageBox: async () => ({ response: 999 }) },
|
||||
});
|
||||
assert.deepEqual(await provider(permissionRequest()), {
|
||||
requestId: "request-1",
|
||||
decision: "deny",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("runtime stopped"));
|
||||
await assert.rejects(provider(permissionRequest(), { signal: controller.signal }), /runtime stopped/);
|
||||
});
|
||||
|
||||
test("terminal interceptor permission prompts omit the unsupported one-use choice", async () => {
|
||||
let captured;
|
||||
const provider = createNativePermissionDecisionProvider({
|
||||
dialog: {
|
||||
async showMessageBox(options) {
|
||||
captured = options;
|
||||
return { response: 1 };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await provider(permissionRequest({
|
||||
sessionId: "session-1",
|
||||
operationId: `terminal.interceptor.input:com.example.${"x".repeat(170)}`,
|
||||
allowedScopes: ["session", "application", "always"],
|
||||
})), {
|
||||
requestId: "request-1",
|
||||
decision: "allow",
|
||||
scope: "session",
|
||||
});
|
||||
assert.deepEqual(captured.buttons, [
|
||||
"Deny",
|
||||
"Allow for Session",
|
||||
"Allow for Application",
|
||||
"Always Allow",
|
||||
]);
|
||||
});
|
||||
|
||||
test("native permission request details are bounded and preserve resource kinds", () => {
|
||||
const resources = Array.from({ length: MAX_VISIBLE_RESOURCES + 5 }, (_, index) => `/path/${index}`);
|
||||
const detail = describePermissionRequest(permissionRequest({
|
||||
resources,
|
||||
resourceKinds: resources.map(() => "exact"),
|
||||
}));
|
||||
assert.match(detail, /and 5 more/u);
|
||||
assert.doesNotMatch(detail, /\/path\/24/u);
|
||||
});
|
||||
|
||||
test("native permission dialogs visibly escape control and bidi characters", async () => {
|
||||
let captured;
|
||||
const provider = createNativePermissionDecisionProvider({
|
||||
dialog: {
|
||||
async showMessageBox(options) {
|
||||
captured = options;
|
||||
return { response: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
await provider(permissionRequest({
|
||||
pluginName: "Plugin\nPermission: vault.credentials",
|
||||
publisher: "publisher\u202e",
|
||||
reason: "Read this\r\nAlways Allow",
|
||||
resources: ["/safe\nPermission: runtime.advanced"],
|
||||
resourceKinds: ["exact"],
|
||||
}));
|
||||
assert.match(captured.message, /Plugin\\nPermission: vault\.credentials/u);
|
||||
assert.match(captured.detail, /publisher\\u202e/u);
|
||||
assert.match(captured.detail, /Read this\\r\\nAlways Allow/u);
|
||||
assert.match(captured.detail, /\/safe\\nPermission: runtime\.advanced/u);
|
||||
assert.equal(captured.detail.split("\n").filter((line) => line.startsWith("Permission:")).length, 1);
|
||||
assert.equal(escapePermissionText("x".repeat(MAX_VISIBLE_FIELD_LENGTH + 20)).length, MAX_VISIBLE_FIELD_LENGTH);
|
||||
});
|
||||
334
electron/plugins/networkBroker.cjs
Normal file
334
electron/plugins/networkBroker.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
|
||||
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
|
||||
|
||||
const MAX_NETWORK_BODY_BYTES = PLUGIN_RPC_MAX_RAW_BYTES;
|
||||
const MAX_NETWORK_HEADERS = 64;
|
||||
const MAX_NETWORK_HEADER_BYTES = 64 * 1024;
|
||||
const MAX_NETWORK_REDIRECTS = 5;
|
||||
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"host",
|
||||
"proxy-authorization",
|
||||
"transfer-encoding",
|
||||
]);
|
||||
const SENSITIVE_REDIRECT_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
|
||||
const ENTITY_REDIRECT_HEADERS = new Set(["content-encoding", "content-language", "content-type"]);
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
async function cancelResponseBody(response, reason) {
|
||||
try { await response.body?.cancel?.(reason); }
|
||||
catch {}
|
||||
}
|
||||
|
||||
function decodeBody(body) {
|
||||
if (body === undefined) return undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.data !== "string") {
|
||||
throw invalidArgument("Plugin network request body is invalid");
|
||||
}
|
||||
let bytes;
|
||||
if (body.encoding === "utf8") bytes = Buffer.from(body.data, "utf8");
|
||||
else if (body.encoding === "base64") {
|
||||
bytes = Buffer.from(body.data, "base64");
|
||||
if (bytes.toString("base64") !== body.data) throw invalidArgument("Plugin network base64 body is not canonical");
|
||||
} else throw invalidArgument("Plugin network request body encoding is unsupported");
|
||||
if (bytes.byteLength > MAX_NETWORK_BODY_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network request body is too large");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function normalizeHeaders(value) {
|
||||
if (value === undefined) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw invalidArgument("Plugin network headers are invalid");
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > MAX_NETWORK_HEADERS) throw invalidArgument("Plugin network request has too many headers");
|
||||
const headers = {};
|
||||
let headerBytes = 0;
|
||||
for (const [rawName, rawValue] of entries) {
|
||||
const name = rawName.toLowerCase();
|
||||
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/u.test(name) || FORBIDDEN_REQUEST_HEADERS.has(name)) {
|
||||
throw invalidArgument(`Plugin network request header is forbidden: ${rawName}`);
|
||||
}
|
||||
if (
|
||||
typeof rawValue !== "string"
|
||||
|| rawValue.length > 8_192
|
||||
|| /[\u0000-\u0008\u000a-\u001f\u007f]/u.test(rawValue)
|
||||
) {
|
||||
throw invalidArgument(`Plugin network request header value is invalid: ${rawName}`);
|
||||
}
|
||||
headerBytes += Buffer.byteLength(name) + Buffer.byteLength(rawValue);
|
||||
if (headerBytes > MAX_NETWORK_HEADER_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network request headers are too large");
|
||||
}
|
||||
headers[name] = rawValue;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function assertNetworkRequest(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw invalidArgument("Plugin network request is invalid");
|
||||
}
|
||||
if (typeof params.url !== "string" || params.url.length > 8_192) {
|
||||
throw invalidArgument("Plugin network URL is invalid");
|
||||
}
|
||||
let url;
|
||||
try { url = new URL(params.url); }
|
||||
catch { throw invalidArgument("Plugin network URL is invalid"); }
|
||||
const origin = canonicalizeNetworkOrigin(url.origin);
|
||||
if (url.username || url.password) throw invalidArgument("Plugin network URL cannot contain credentials");
|
||||
const method = params.method ?? "GET";
|
||||
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(method)) {
|
||||
throw invalidArgument("Plugin network method is unsupported");
|
||||
}
|
||||
const timeoutMs = params.timeoutMs ?? 30_000;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
|
||||
throw invalidArgument("Plugin network timeout is invalid");
|
||||
}
|
||||
const body = decodeBody(params.body);
|
||||
if (body && (method === "GET" || method === "HEAD")) {
|
||||
throw invalidArgument(`Plugin network ${method} requests cannot contain a body`);
|
||||
}
|
||||
return {
|
||||
url,
|
||||
origin,
|
||||
method,
|
||||
headers: normalizeHeaders(params.headers),
|
||||
body,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBoundedResponse(response, maxBytes, signal) {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await cancelResponseBody(response);
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response is too large");
|
||||
}
|
||||
if (!response.body) return Buffer.alloc(0);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response is too large");
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
} catch (error) {
|
||||
try { await reader.cancel(error); } catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function normalizeResponseHeaders(headers) {
|
||||
const responseHeaders = {};
|
||||
let responseHeaderBytes = 0;
|
||||
let responseHeaderCount = 0;
|
||||
for (const [name, value] of headers) {
|
||||
if (name.toLowerCase() === "set-cookie") continue;
|
||||
responseHeaderCount += 1;
|
||||
responseHeaderBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
||||
if (responseHeaderCount > MAX_NETWORK_HEADERS || responseHeaderBytes > MAX_NETWORK_HEADER_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response headers are too large");
|
||||
}
|
||||
responseHeaders[name.toLowerCase()] = value;
|
||||
}
|
||||
return responseHeaders;
|
||||
}
|
||||
|
||||
class PluginNetworkBroker {
|
||||
constructor(options) {
|
||||
this.fetch = options.fetch ?? globalThis.fetch;
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
if (typeof this.fetch !== "function") throw new TypeError("Plugin network fetch implementation is required");
|
||||
}
|
||||
|
||||
validate(params) {
|
||||
const request = assertNetworkRequest(params);
|
||||
const validated = {
|
||||
url: request.url.href,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
...(request.body === undefined ? {} : {
|
||||
body: { encoding: "base64", data: request.body.toString("base64") },
|
||||
}),
|
||||
timeoutMs: request.timeoutMs,
|
||||
};
|
||||
// Preserve host-consumed credential fields. assertNetworkRequest ignores
|
||||
// unknown keys; without this, registry validateParams would strip them and
|
||||
// network.request lease injection would become dead code.
|
||||
if (params?.credentialLease !== undefined) {
|
||||
const lease = params.credentialLease;
|
||||
if (
|
||||
!lease
|
||||
|| typeof lease !== "object"
|
||||
|| Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string"
|
||||
|| typeof lease.operationId !== "string"
|
||||
|| !Number.isFinite(lease.expiresAt)
|
||||
) {
|
||||
throw invalidArgument("Plugin network credential lease is invalid");
|
||||
}
|
||||
validated.credentialLease = Object.freeze({
|
||||
kind: "secret-lease",
|
||||
id: lease.id,
|
||||
operationId: lease.operationId,
|
||||
expiresAt: lease.expiresAt,
|
||||
});
|
||||
}
|
||||
if (params?.authorization !== undefined) {
|
||||
const authorization = params.authorization;
|
||||
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
|
||||
throw invalidArgument("Plugin network authorization is invalid");
|
||||
}
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
validated.authorization = Object.freeze({
|
||||
scheme,
|
||||
...(typeof authorization.username === "string"
|
||||
? { username: authorization.username }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
describeAuthorization(params) {
|
||||
const request = assertNetworkRequest(params);
|
||||
return {
|
||||
permission: "network",
|
||||
resources: [request.origin],
|
||||
reason: `Connect to ${request.origin}`,
|
||||
operationId: `network:${request.origin}`,
|
||||
};
|
||||
}
|
||||
|
||||
async request(params, context) {
|
||||
const request = assertNetworkRequest(params);
|
||||
const timeout = AbortSignal.timeout(request.timeoutMs);
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, timeout]) : timeout;
|
||||
let currentUrl = request.url;
|
||||
let currentOrigin = request.origin;
|
||||
let currentMethod = request.method;
|
||||
let currentBody = request.body;
|
||||
let headers = { ...request.headers };
|
||||
try {
|
||||
for (let redirects = 0; redirects <= MAX_NETWORK_REDIRECTS; redirects += 1) {
|
||||
await context.assertActive();
|
||||
if (currentBody) {
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "network", currentBody.byteLength);
|
||||
}
|
||||
const response = await this.fetch(currentUrl, {
|
||||
method: currentMethod,
|
||||
headers,
|
||||
...(currentBody === undefined ? {} : { body: currentBody }),
|
||||
credentials: "omit",
|
||||
redirect: "manual",
|
||||
signal,
|
||||
});
|
||||
const effectiveUrl = response.url ? new URL(response.url) : currentUrl;
|
||||
if (
|
||||
effectiveUrl.username
|
||||
|| effectiveUrl.password
|
||||
|| canonicalizeNetworkOrigin(effectiveUrl.origin) !== currentOrigin
|
||||
) {
|
||||
await cancelResponseBody(response);
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Plugin network transport followed an unauthorized redirect",
|
||||
);
|
||||
}
|
||||
let responseHeaders;
|
||||
try {
|
||||
responseHeaders = normalizeResponseHeaders(response.headers);
|
||||
} catch (error) {
|
||||
await cancelResponseBody(response, error);
|
||||
throw error;
|
||||
}
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
||||
await cancelResponseBody(response);
|
||||
if (redirects === MAX_NETWORK_REDIRECTS) {
|
||||
throw new PluginRpcError(RPC_ERRORS.outOfRange, "Plugin network redirect limit exceeded");
|
||||
}
|
||||
const nextUrl = new URL(response.headers.get("location"), currentUrl);
|
||||
if (nextUrl.username || nextUrl.password) {
|
||||
throw invalidArgument("Plugin network redirect URL cannot contain credentials");
|
||||
}
|
||||
const nextOrigin = canonicalizeNetworkOrigin(nextUrl.origin);
|
||||
await this.permissionEngine.authorize(context, {
|
||||
permission: "network",
|
||||
resources: [nextOrigin],
|
||||
reason: `Follow network redirect to ${nextOrigin}`,
|
||||
operationId: `network:${nextOrigin}`,
|
||||
});
|
||||
if (nextOrigin !== currentOrigin) {
|
||||
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => (
|
||||
!SENSITIVE_REDIRECT_HEADERS.has(name)
|
||||
)));
|
||||
}
|
||||
if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentMethod === "POST")) {
|
||||
currentMethod = currentMethod === "HEAD" ? "HEAD" : "GET";
|
||||
currentBody = undefined;
|
||||
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => (
|
||||
!ENTITY_REDIRECT_HEADERS.has(name)
|
||||
)));
|
||||
}
|
||||
currentUrl = nextUrl;
|
||||
currentOrigin = nextOrigin;
|
||||
continue;
|
||||
}
|
||||
const bytes = await readBoundedResponse(response, MAX_NETWORK_BODY_BYTES, signal);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "network", bytes.byteLength);
|
||||
await context.assertActive();
|
||||
return {
|
||||
url: effectiveUrl.href,
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
body: { encoding: "base64", data: bytes.toString("base64") },
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
context.signal?.throwIfAborted();
|
||||
if (timeout.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.deadlineExceeded, "Plugin network request timed out");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new PluginRpcError(RPC_ERRORS.internal, "Plugin network redirect handling failed");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORBIDDEN_REQUEST_HEADERS,
|
||||
ENTITY_REDIRECT_HEADERS,
|
||||
MAX_NETWORK_BODY_BYTES,
|
||||
MAX_NETWORK_HEADER_BYTES,
|
||||
MAX_NETWORK_HEADERS,
|
||||
MAX_NETWORK_REDIRECTS,
|
||||
PluginNetworkBroker,
|
||||
assertNetworkRequest,
|
||||
cancelResponseBody,
|
||||
normalizeResponseHeaders,
|
||||
readBoundedResponse,
|
||||
};
|
||||
171
electron/plugins/networkBroker.test.cjs
Normal file
171
electron/plugins/networkBroker.test.cjs
Normal file
@@ -0,0 +1,171 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PluginNetworkBroker,
|
||||
MAX_NETWORK_BODY_BYTES,
|
||||
MAX_NETWORK_HEADER_BYTES,
|
||||
} = require("./networkBroker.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function runtimeContext() {
|
||||
return {
|
||||
pluginId: "com.example.network",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: { permissions: { optional: ["network"] } },
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test("network broker strips cookies, bounds responses, and charges decoded bytes", async () => {
|
||||
const calls = [];
|
||||
const charged = [];
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return new Response("hello", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain", "set-cookie": "secret=1" },
|
||||
});
|
||||
},
|
||||
permissionEngine: { authorize: async () => {} },
|
||||
quotaManager: { chargeBytes: (...args) => charged.push(args) },
|
||||
});
|
||||
const response = await broker.request({
|
||||
url: "https://example.com/data",
|
||||
headers: { accept: "text/plain" },
|
||||
}, runtimeContext());
|
||||
assert.equal(calls[0].options.credentials, "omit");
|
||||
assert.equal(calls[0].options.redirect, "manual");
|
||||
assert.equal(response.headers["set-cookie"], undefined);
|
||||
assert.equal(Buffer.from(response.body.data, "base64").toString(), "hello");
|
||||
assert.deepEqual(charged, [["runtime-1", "network", 5]]);
|
||||
});
|
||||
|
||||
test("every redirect origin is authorized and cross-origin credentials are stripped", async () => {
|
||||
const calls = [];
|
||||
const authorized = [];
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
if (calls.length === 1) {
|
||||
return new Response(null, { status: 302, headers: { location: "https://other.example/final" } });
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
},
|
||||
permissionEngine: { authorize: async (_context, descriptor) => authorized.push(descriptor) },
|
||||
});
|
||||
await broker.request({
|
||||
url: "https://first.example/start",
|
||||
headers: { authorization: "Bearer host-secret", "x-request-id": "one" },
|
||||
}, runtimeContext());
|
||||
assert.equal(authorized[0].resources[0], "https://other.example");
|
||||
assert.equal(calls[1].options.headers.authorization, undefined);
|
||||
assert.equal(calls[1].options.headers["x-request-id"], "one");
|
||||
});
|
||||
|
||||
test("HTTP redirect method semantics do not replay POST bodies on 302", async () => {
|
||||
const calls = [];
|
||||
const charges = [];
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async (_url, options) => {
|
||||
calls.push(options);
|
||||
if (calls.length === 1) return new Response(null, { status: 302, headers: { location: "/done" } });
|
||||
return new Response("ok");
|
||||
},
|
||||
permissionEngine: { authorize: async () => {} },
|
||||
quotaManager: { chargeBytes: (...args) => charges.push(args) },
|
||||
});
|
||||
await broker.request({
|
||||
url: "https://example.com/start",
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: { encoding: "utf8", data: "{}" },
|
||||
}, runtimeContext());
|
||||
assert.equal(calls[1].method, "GET");
|
||||
assert.equal(calls[1].body, undefined);
|
||||
assert.equal(calls[1].headers["content-type"], undefined);
|
||||
assert.deepEqual(charges, [
|
||||
["runtime-1", "network", 2],
|
||||
["runtime-1", "network", 2],
|
||||
], "the request body and final response are both charged");
|
||||
});
|
||||
|
||||
test("network redirects reject embedded credentials before authorization or fetch", async () => {
|
||||
let calls = 0;
|
||||
let authorizations = 0;
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async () => {
|
||||
calls += 1;
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://user:password@other.example/private" },
|
||||
});
|
||||
},
|
||||
permissionEngine: { authorize: async () => { authorizations += 1; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.request({ url: "https://first.example/start" }, runtimeContext()),
|
||||
/cannot contain credentials/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(authorizations, 0);
|
||||
});
|
||||
|
||||
test("network request validation rejects transport headers, credentials, and non-canonical bodies", () => {
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async () => new Response(),
|
||||
permissionEngine: { authorize: async () => {} },
|
||||
});
|
||||
assert.throws(() => broker.validate({
|
||||
url: "https://example.com",
|
||||
headers: { cookie: "secret=1" },
|
||||
}), /forbidden/);
|
||||
assert.throws(() => broker.validate({ url: "https://user:pass@example.com" }), /credentials/);
|
||||
assert.throws(() => broker.validate({
|
||||
url: "https://example.com",
|
||||
method: "POST",
|
||||
body: { encoding: "base64", data: "YQ" },
|
||||
}), /not canonical/);
|
||||
assert.throws(() => broker.validate({
|
||||
url: "https://example.com",
|
||||
headers: Object.fromEntries(Array.from({ length: 9 }, (_, index) => (
|
||||
[`x-budget-${index}`, "x".repeat(MAX_NETWORK_HEADER_BYTES / 8)]
|
||||
))),
|
||||
}), /headers are too large/);
|
||||
assert.throws(() => broker.validate({
|
||||
url: "https://example.com",
|
||||
headers: { "x-control": "before\0after" },
|
||||
}), /header value is invalid/);
|
||||
});
|
||||
|
||||
test("network response streaming stops above the byte cap", async () => {
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async () => new Response("small", {
|
||||
headers: { "content-length": String(MAX_NETWORK_BODY_BYTES + 1) },
|
||||
}),
|
||||
permissionEngine: { authorize: async () => {} },
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.request({ url: "https://example.com" }, runtimeContext()),
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
test("network broker reports its own timeout as a stable deadline error", async () => {
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async (_url, options) => new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
|
||||
}),
|
||||
permissionEngine: { authorize: async () => {} },
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.request({ url: "https://example.com", timeoutMs: 5 }, runtimeContext()),
|
||||
(error) => error.code === RPC_ERRORS.deadlineExceeded,
|
||||
);
|
||||
});
|
||||
589
electron/plugins/packageStore.cjs
Normal file
589
electron/plugins/packageStore.cjs
Normal file
@@ -0,0 +1,589 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const { constants } = fs;
|
||||
const {
|
||||
mkdir,
|
||||
lstat,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
} = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
assertPluginStorageSegment,
|
||||
isPathInside,
|
||||
resolveInstalledVersionDirectory,
|
||||
} = require("./paths.cjs");
|
||||
|
||||
const INSTALL_METADATA_FILE = "install.json";
|
||||
const ARCHIVE_SNAPSHOT_FILE = "package.ncpkg";
|
||||
const PACKAGE_DIRECTORY = "package";
|
||||
const REMOVAL_METADATA_FILE = "remove.json";
|
||||
const REMOVED_PLUGIN_DIRECTORY = "plugin";
|
||||
|
||||
async function loadPluginCli() {
|
||||
return import("@netcatty/plugin-cli");
|
||||
}
|
||||
|
||||
async function syncDirectory(directory) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(directory, constants.O_RDONLY);
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (process.platform !== "win32") throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDurableMetadata(filePath, value) {
|
||||
const handle = await open(filePath, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectoryTree(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) await syncDirectoryTree(path.join(directory, entry.name));
|
||||
}
|
||||
await syncDirectory(directory);
|
||||
}
|
||||
|
||||
async function copyImmutableArchive(sourcePath, destinationPath, maxBytes) {
|
||||
const sourcePathStats = await lstat(sourcePath);
|
||||
if (!sourcePathStats.isFile() || sourcePathStats.isSymbolicLink()) {
|
||||
throw new Error("Plugin package source must be a regular non-symbolic file");
|
||||
}
|
||||
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
||||
const source = await open(sourcePath, constants.O_RDONLY | noFollow);
|
||||
let destination;
|
||||
try {
|
||||
const before = await source.stat();
|
||||
if (!before.isFile()) throw new Error("Plugin package source must be a regular file");
|
||||
if (before.size > maxBytes) throw new Error(`Plugin archive exceeds ${maxBytes} bytes`);
|
||||
destination = await open(destinationPath, "wx", 0o600);
|
||||
const sha256 = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
let position = 0;
|
||||
while (position < before.size) {
|
||||
const { bytesRead } = await source.read(buffer, 0, buffer.length, position);
|
||||
if (bytesRead === 0) break;
|
||||
position += bytesRead;
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
sha256.update(chunk);
|
||||
let written = 0;
|
||||
while (written < chunk.length) {
|
||||
const result = await destination.write(chunk, written, chunk.length - written);
|
||||
if (result.bytesWritten === 0) throw new Error("Unable to stage the complete plugin package");
|
||||
written += result.bytesWritten;
|
||||
}
|
||||
}
|
||||
const after = await source.stat();
|
||||
if (
|
||||
position !== before.size
|
||||
|| after.size !== before.size
|
||||
|| after.mtimeMs !== before.mtimeMs
|
||||
|| (before.ino && after.ino && before.ino !== after.ino)
|
||||
) {
|
||||
throw new Error("Plugin package source changed while it was staged");
|
||||
}
|
||||
await destination.sync();
|
||||
return { bytes: position, sha256: sha256.digest("hex") };
|
||||
} finally {
|
||||
await destination?.close();
|
||||
await source.close();
|
||||
}
|
||||
}
|
||||
|
||||
function validateInstallMetadata(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Invalid plugin install metadata");
|
||||
}
|
||||
const pluginId = assertPluginStorageSegment(value.pluginId, "ID");
|
||||
const version = assertPluginStorageSegment(value.version, "version");
|
||||
if (!/^[a-f0-9]{64}$/u.test(value.archiveSha256)) {
|
||||
throw new Error("Invalid plugin install archive hash");
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/u.test(value.contentSha256)) {
|
||||
throw new Error("Invalid plugin install content hash");
|
||||
}
|
||||
return {
|
||||
pluginId,
|
||||
version,
|
||||
archiveSha256: value.archiveSha256,
|
||||
contentSha256: value.contentSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function validateRemovalMetadata(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Invalid plugin removal metadata");
|
||||
}
|
||||
return { pluginId: assertPluginStorageSegment(value.pluginId, "ID") };
|
||||
}
|
||||
|
||||
class PackageStore {
|
||||
constructor(options) {
|
||||
this.paths = options.paths;
|
||||
this.database = options.database;
|
||||
this.netcattyVersion = options.netcattyVersion;
|
||||
this.apiVersion = options.apiVersion;
|
||||
this.supportedFeatures = [...(options.supportedFeatures ?? [])];
|
||||
this.logger = options.logger ?? console;
|
||||
this.syncDirectory = options.syncDirectory ?? syncDirectory;
|
||||
this.verifiedArchives = new Map();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await Promise.all(Object.values(this.paths)
|
||||
.filter((value) => typeof value === "string" && value !== this.paths.database)
|
||||
.map((directory) => mkdir(directory, { recursive: true, mode: 0o700 })));
|
||||
await this.recover();
|
||||
}
|
||||
|
||||
async install(archivePath, options = {}) {
|
||||
if (typeof archivePath !== "string" || !path.isAbsolute(archivePath)) {
|
||||
throw new TypeError("Plugin package path must be absolute");
|
||||
}
|
||||
if (path.extname(archivePath).toLowerCase() !== ".ncpkg") {
|
||||
throw new Error("Plugin packages must use the .ncpkg extension");
|
||||
}
|
||||
const pluginCli = await loadPluginCli();
|
||||
const stagingName = `install-${randomUUID()}`;
|
||||
const stagingDirectory = path.join(this.paths.staging, stagingName);
|
||||
const archiveSnapshot = path.join(stagingDirectory, ARCHIVE_SNAPSHOT_FILE);
|
||||
const extractedDirectory = path.join(stagingDirectory, PACKAGE_DIRECTORY);
|
||||
await mkdir(stagingDirectory, { recursive: false, mode: 0o700 });
|
||||
try {
|
||||
const snapshot = await copyImmutableArchive(
|
||||
archivePath,
|
||||
archiveSnapshot,
|
||||
pluginCli.PACKAGE_LIMITS.archiveBytes,
|
||||
);
|
||||
const validation = await pluginCli.extractPluginPackage(archiveSnapshot, extractedDirectory);
|
||||
await syncDirectoryTree(extractedDirectory);
|
||||
const manifest = validation.manifest;
|
||||
const compatibility = pluginCli.checkPluginCompatibility(manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
features: this.supportedFeatures,
|
||||
});
|
||||
if (!compatibility.compatible) {
|
||||
throw new Error(`Plugin is incompatible: ${compatibility.errors.join("; ")}`);
|
||||
}
|
||||
const pluginId = assertPluginStorageSegment(manifest.id, "ID");
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
|
||||
let activationPrepared = false;
|
||||
const prepareActivation = async (reason) => {
|
||||
if (activationPrepared) return;
|
||||
if (typeof options.beforeActivate === "function") {
|
||||
await options.beforeActivate(Object.freeze({
|
||||
pluginId,
|
||||
version,
|
||||
previousPlugin,
|
||||
reason,
|
||||
}));
|
||||
}
|
||||
activationPrepared = true;
|
||||
};
|
||||
const existing = this.database.getVersion(pluginId, version);
|
||||
if (existing) {
|
||||
if (existing.archiveSha256 !== snapshot.sha256) {
|
||||
throw new Error(`Plugin ${pluginId}@${version} is already installed with different contents`);
|
||||
}
|
||||
let existingValidation;
|
||||
try {
|
||||
existingValidation = await this.verifyInstalledVersion(existing, { refreshArchive: true });
|
||||
} catch {
|
||||
if (previousPlugin?.activeVersion === version) await prepareActivation("replace-active-files");
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
if (existingValidation) {
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
pluginId,
|
||||
version,
|
||||
manifest: existingValidation.manifest,
|
||||
archiveSha256: existing.archiveSha256,
|
||||
packageRelativePath: path.relative(this.paths.packages, existingPackage),
|
||||
}, { enable: enableAfterActivation });
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await stat(targetDirectory);
|
||||
throw new Error(`Plugin ${pluginId}@${version} has an uncommitted installed directory`);
|
||||
} catch (error) {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
const metadata = {
|
||||
pluginId,
|
||||
version,
|
||||
archiveSha256: snapshot.sha256,
|
||||
contentSha256: validation.contentSha256,
|
||||
};
|
||||
await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata);
|
||||
await this.syncDirectory(stagingDirectory);
|
||||
await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 });
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
await rename(stagingDirectory, targetDirectory);
|
||||
await this.syncDirectory(path.dirname(targetDirectory));
|
||||
const packageRelativePath = path.relative(
|
||||
this.paths.packages,
|
||||
path.join(targetDirectory, PACKAGE_DIRECTORY),
|
||||
);
|
||||
try {
|
||||
this.database.installVersion({
|
||||
pluginId,
|
||||
version,
|
||||
manifest,
|
||||
archiveSha256: snapshot.sha256,
|
||||
packageRelativePath,
|
||||
}, { enable: enableAfterActivation });
|
||||
} catch (error) {
|
||||
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
await this.syncDirectory(path.dirname(targetDirectory));
|
||||
throw error;
|
||||
}
|
||||
this.verifiedArchives.set(this.#versionKey(pluginId, version), Object.freeze({
|
||||
archiveSha256: snapshot.sha256,
|
||||
contentSha256: validation.contentSha256,
|
||||
manifest,
|
||||
}));
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
} finally {
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async recover() {
|
||||
this.verifiedArchives.clear();
|
||||
await mkdir(this.paths.staging, { recursive: true, mode: 0o700 });
|
||||
const stagedEntries = await readdir(this.paths.staging, { withFileTypes: true });
|
||||
for (const entry of stagedEntries) {
|
||||
const stagedPath = path.join(this.paths.staging, entry.name);
|
||||
if (!entry.isDirectory() || !entry.name.startsWith("remove-")) {
|
||||
await rm(stagedPath, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
const removedPluginPath = path.join(stagedPath, REMOVED_PLUGIN_DIRECTORY);
|
||||
let hasMovedPlugin = false;
|
||||
try {
|
||||
await lstat(removedPluginPath);
|
||||
hasMovedPlugin = true;
|
||||
} catch (error) {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
// A crash can happen after remove-* is created but before remove.json is
|
||||
// durably written or before the installed package is moved. With no
|
||||
// moved package there is nothing to restore, so discard the debris even
|
||||
// when the metadata is missing or partial. If plugin/ exists, metadata
|
||||
// remains mandatory so recovery never deletes an unidentified package.
|
||||
if (!hasMovedPlugin) {
|
||||
await rm(stagedPath, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
const metadata = validateRemovalMetadata(JSON.parse(await readFile(
|
||||
path.join(stagedPath, REMOVAL_METADATA_FILE),
|
||||
"utf8",
|
||||
)));
|
||||
const installedPluginPath = path.join(this.paths.packages, metadata.pluginId);
|
||||
const databasePlugin = this.database.getActivePlugin(metadata.pluginId);
|
||||
if (databasePlugin) {
|
||||
let installedExists = false;
|
||||
try {
|
||||
const installedStats = await lstat(installedPluginPath);
|
||||
installedExists = installedStats.isDirectory() && !installedStats.isSymbolicLink();
|
||||
} catch (error) {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
if (!installedExists) {
|
||||
const removedStats = await lstat(removedPluginPath);
|
||||
if (!removedStats.isDirectory() || removedStats.isSymbolicLink()) {
|
||||
throw new Error(`Pending plugin removal is incomplete: ${metadata.pluginId}`);
|
||||
}
|
||||
await rename(removedPluginPath, installedPluginPath);
|
||||
await this.syncDirectory(this.paths.packages);
|
||||
}
|
||||
}
|
||||
await rm(stagedPath, { recursive: true, force: true });
|
||||
}
|
||||
const pluginDirectories = await readdir(this.paths.packages, { withFileTypes: true });
|
||||
for (const pluginEntry of pluginDirectories) {
|
||||
if (!pluginEntry.isDirectory()) continue;
|
||||
const pluginDirectory = path.join(this.paths.packages, pluginEntry.name);
|
||||
const versions = await readdir(pluginDirectory, { withFileTypes: true });
|
||||
for (const versionEntry of versions) {
|
||||
if (!versionEntry.isDirectory()) continue;
|
||||
const versionDirectory = path.join(pluginDirectory, versionEntry.name);
|
||||
const committedVersion = this.database.getVersion(pluginEntry.name, versionEntry.name);
|
||||
try {
|
||||
const metadata = validateInstallMetadata(JSON.parse(await readFile(
|
||||
path.join(versionDirectory, INSTALL_METADATA_FILE),
|
||||
"utf8",
|
||||
)));
|
||||
if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) {
|
||||
throw new Error("Plugin install metadata does not match its directory");
|
||||
}
|
||||
if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) {
|
||||
throw new Error("Plugin install archive hash does not match its database record");
|
||||
}
|
||||
const validation = await this.verifyInstalledVersion(committedVersion ?? {
|
||||
pluginId: metadata.pluginId,
|
||||
version: metadata.version,
|
||||
archiveSha256: metadata.archiveSha256,
|
||||
packageRelativePath: path.relative(
|
||||
this.paths.packages,
|
||||
path.join(versionDirectory, PACKAGE_DIRECTORY),
|
||||
),
|
||||
});
|
||||
if (!committedVersion) {
|
||||
const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
pluginId: metadata.pluginId,
|
||||
version: metadata.version,
|
||||
manifest: validation.manifest,
|
||||
archiveSha256: metadata.archiveSha256,
|
||||
packageRelativePath: path.relative(this.paths.packages, packageDirectory),
|
||||
}, { forceDisabled: true });
|
||||
}
|
||||
} catch (error) {
|
||||
this.verifiedArchives.delete(this.#versionKey(pluginEntry.name, versionEntry.name));
|
||||
this.logger.warn?.(committedVersion
|
||||
? "[Plugins] Retaining invalid committed package fail-closed"
|
||||
: "[Plugins] Removing invalid uncommitted package", {
|
||||
directory: versionDirectory,
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
if (committedVersion) {
|
||||
const activePlugin = this.database.getActivePlugin(committedVersion.pluginId);
|
||||
if (activePlugin?.activeVersion === committedVersion.version) {
|
||||
this.database.setEnabled(committedVersion.pluginId, false);
|
||||
this.database.setRuntimeState(committedVersion.pluginId, "error", {
|
||||
pluginVersion: committedVersion.version,
|
||||
error: `Installed package integrity check failed: ${error?.message ?? String(error)}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await rm(versionDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.packageRelativePath) continue;
|
||||
try {
|
||||
await this.preparePackageRoot(plugin);
|
||||
} catch (error) {
|
||||
this.database.setEnabled(plugin.id, false);
|
||||
this.database.setRuntimeState(plugin.id, "error", {
|
||||
error: `Installed package integrity check failed: ${error?.message ?? String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolvePackageRoot(plugin) {
|
||||
const relativePath = plugin?.packageRelativePath;
|
||||
if (typeof relativePath !== "string" || path.isAbsolute(relativePath)) {
|
||||
throw new Error("Plugin package path is invalid");
|
||||
}
|
||||
const packageRoot = path.resolve(this.paths.packages, relativePath);
|
||||
if (!isPathInside(this.paths.packages, packageRoot)) {
|
||||
throw new Error("Plugin package path escapes the package store");
|
||||
}
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
#versionKey(pluginId, version) {
|
||||
return `${pluginId}\0${version}`;
|
||||
}
|
||||
|
||||
#recordIdentity(record) {
|
||||
const pluginId = assertPluginStorageSegment(record?.pluginId ?? record?.id, "ID");
|
||||
const version = assertPluginStorageSegment(record?.version ?? record?.activeVersion, "version");
|
||||
if (!/^[a-f0-9]{64}$/u.test(record?.archiveSha256)) {
|
||||
throw new Error("Plugin database archive hash is invalid");
|
||||
}
|
||||
return { pluginId, version, archiveSha256: record.archiveSha256 };
|
||||
}
|
||||
|
||||
async #loadVerifiedArchive(record, options = {}) {
|
||||
const identity = this.#recordIdentity(record);
|
||||
const key = this.#versionKey(identity.pluginId, identity.version);
|
||||
const cached = this.verifiedArchives.get(key);
|
||||
if (options.refresh !== true && cached?.archiveSha256 === identity.archiveSha256) return cached;
|
||||
|
||||
const pluginCli = await loadPluginCli();
|
||||
const versionDirectory = resolveInstalledVersionDirectory(
|
||||
this.paths,
|
||||
identity.pluginId,
|
||||
identity.version,
|
||||
);
|
||||
const metadata = validateInstallMetadata(JSON.parse(await readFile(
|
||||
path.join(versionDirectory, INSTALL_METADATA_FILE),
|
||||
"utf8",
|
||||
)));
|
||||
if (
|
||||
metadata.pluginId !== identity.pluginId
|
||||
|| metadata.version !== identity.version
|
||||
|| metadata.archiveSha256 !== identity.archiveSha256
|
||||
) {
|
||||
throw new Error("Plugin install metadata does not match its database record");
|
||||
}
|
||||
|
||||
const verificationDirectory = path.join(this.paths.staging, `verify-${randomUUID()}`);
|
||||
const verificationSnapshot = path.join(verificationDirectory, ARCHIVE_SNAPSHOT_FILE);
|
||||
await mkdir(verificationDirectory, { recursive: false, mode: 0o700 });
|
||||
try {
|
||||
const snapshot = await copyImmutableArchive(
|
||||
path.join(versionDirectory, ARCHIVE_SNAPSHOT_FILE),
|
||||
verificationSnapshot,
|
||||
pluginCli.PACKAGE_LIMITS.archiveBytes,
|
||||
);
|
||||
if (snapshot.sha256 !== identity.archiveSha256) {
|
||||
throw new Error("Retained plugin archive hash does not match its database record");
|
||||
}
|
||||
const validation = await pluginCli.validatePluginPackage(verificationSnapshot);
|
||||
if (validation.contentSha256 !== metadata.contentSha256) {
|
||||
throw new Error("Retained plugin archive content does not match install metadata");
|
||||
}
|
||||
if (
|
||||
validation.manifest.id !== identity.pluginId
|
||||
|| validation.manifest.version !== identity.version
|
||||
) {
|
||||
throw new Error("Retained plugin archive identity does not match its database record");
|
||||
}
|
||||
const verified = Object.freeze({
|
||||
archiveSha256: identity.archiveSha256,
|
||||
contentSha256: validation.contentSha256,
|
||||
manifest: validation.manifest,
|
||||
});
|
||||
this.verifiedArchives.set(key, verified);
|
||||
return verified;
|
||||
} finally {
|
||||
await rm(verificationDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async verifyInstalledVersion(record, options = {}) {
|
||||
const identity = this.#recordIdentity(record);
|
||||
const expected = await this.#loadVerifiedArchive(record, {
|
||||
refresh: options.refreshArchive === true,
|
||||
});
|
||||
const packageRoot = this.resolvePackageRoot({
|
||||
packageRelativePath: record.packageRelativePath,
|
||||
});
|
||||
const packageStats = await stat(packageRoot);
|
||||
if (!packageStats.isDirectory()) throw new Error("Plugin package root is not a directory");
|
||||
const pluginCli = await loadPluginCli();
|
||||
const validation = await pluginCli.validatePluginDirectory(packageRoot, {
|
||||
allowIgnoredRootEntries: false,
|
||||
});
|
||||
if (
|
||||
validation.manifest.id !== identity.pluginId
|
||||
|| validation.manifest.version !== identity.version
|
||||
) {
|
||||
throw new Error("Installed plugin identity does not match its database record");
|
||||
}
|
||||
if (validation.contentSha256 !== expected.contentSha256) {
|
||||
throw new Error("Installed plugin files do not match the retained package archive");
|
||||
}
|
||||
if (record.manifest && JSON.stringify(record.manifest) !== JSON.stringify(expected.manifest)) {
|
||||
throw new Error("Plugin database manifest does not match the retained package archive");
|
||||
}
|
||||
return validation;
|
||||
}
|
||||
|
||||
async preparePackageRoot(plugin) {
|
||||
try {
|
||||
await this.verifyInstalledVersion(plugin);
|
||||
return this.resolvePackageRoot(plugin);
|
||||
} catch (error) {
|
||||
const identity = this.#recordIdentity(plugin);
|
||||
const active = this.database.getActivePlugin(identity.pluginId);
|
||||
if (active?.activeVersion === identity.version) {
|
||||
this.database.setEnabled(identity.pluginId, false);
|
||||
this.database.setRuntimeState(identity.pluginId, "error", {
|
||||
pluginVersion: identity.version,
|
||||
error: `Installed package integrity check failed: ${error?.message ?? String(error)}`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(pluginId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin) return false;
|
||||
const pluginDirectory = path.join(
|
||||
this.paths.packages,
|
||||
assertPluginStorageSegment(pluginId, "ID"),
|
||||
);
|
||||
const removalDirectory = path.join(this.paths.staging, `remove-${randomUUID()}`);
|
||||
const removedPluginPath = path.join(removalDirectory, REMOVED_PLUGIN_DIRECTORY);
|
||||
await mkdir(removalDirectory, { recursive: false, mode: 0o700 });
|
||||
await writeDurableMetadata(
|
||||
path.join(removalDirectory, REMOVAL_METADATA_FILE),
|
||||
{ pluginId },
|
||||
);
|
||||
await syncDirectory(removalDirectory);
|
||||
let moved = false;
|
||||
try {
|
||||
await rename(pluginDirectory, removedPluginPath);
|
||||
moved = true;
|
||||
await this.syncDirectory(removalDirectory);
|
||||
await this.syncDirectory(this.paths.packages);
|
||||
} catch (error) {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
try {
|
||||
this.database.removePlugin(pluginId);
|
||||
} catch (error) {
|
||||
let restored = !moved;
|
||||
if (moved) {
|
||||
try {
|
||||
await rename(removedPluginPath, pluginDirectory);
|
||||
await this.syncDirectory(this.paths.packages);
|
||||
restored = true;
|
||||
} catch {}
|
||||
}
|
||||
if (restored) await rm(removalDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
await rm(removalDirectory, { recursive: true, force: true });
|
||||
for (const key of this.verifiedArchives.keys()) {
|
||||
if (key.startsWith(`${pluginId}\0`)) this.verifiedArchives.delete(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ARCHIVE_SNAPSHOT_FILE,
|
||||
INSTALL_METADATA_FILE,
|
||||
PACKAGE_DIRECTORY,
|
||||
REMOVAL_METADATA_FILE,
|
||||
REMOVED_PLUGIN_DIRECTORY,
|
||||
PackageStore,
|
||||
copyImmutableArchive,
|
||||
validateInstallMetadata,
|
||||
validateRemovalMetadata,
|
||||
};
|
||||
372
electron/plugins/packageStore.test.cjs
Normal file
372
electron/plugins/packageStore.test.cjs
Normal file
@@ -0,0 +1,372 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { createHash } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const {
|
||||
ARCHIVE_SNAPSHOT_FILE,
|
||||
PackageStore,
|
||||
REMOVAL_METADATA_FILE,
|
||||
REMOVED_PLUGIN_DIRECTORY,
|
||||
} = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
|
||||
async function createPackage(root, overrides = {}) {
|
||||
const source = path.join(root, `source-${Math.random().toString(16).slice(2)}`);
|
||||
await fs.promises.mkdir(path.join(source, "dist"), { recursive: true });
|
||||
const pluginManifest = {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.runtime-test",
|
||||
name: "runtime-test",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "dist/index.js" },
|
||||
...overrides.manifest,
|
||||
};
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(
|
||||
path.join(source, "netcatty.plugin.json"),
|
||||
`${JSON.stringify(pluginManifest, null, 2)}\n`,
|
||||
),
|
||||
fs.promises.writeFile(path.join(source, "dist/index.js"), overrides.source ?? "export default {};\n"),
|
||||
]);
|
||||
const archive = path.join(root, `${pluginManifest.id}-${Date.now()}-${Math.random()}.ncpkg`);
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
await buildPluginPackage(source, archive);
|
||||
return { archive, manifest: pluginManifest };
|
||||
}
|
||||
|
||||
function createStore(context, options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-store-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const paths = createPluginPaths(root);
|
||||
const database = new PluginDatabase(paths.database);
|
||||
context.after(() => {
|
||||
try { database.close(); } catch {}
|
||||
});
|
||||
const store = new PackageStore({
|
||||
paths,
|
||||
database,
|
||||
netcattyVersion: "0.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
...options,
|
||||
});
|
||||
return { root, paths, database, store };
|
||||
}
|
||||
|
||||
test("package install stages, validates, atomically publishes, and records the active version", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
assert.equal(installed.id, pluginPackage.manifest.id);
|
||||
assert.equal(installed.enabled, true);
|
||||
const packageRoot = fixture.store.resolvePackageRoot(installed);
|
||||
assert.equal(
|
||||
await fs.promises.readFile(path.join(packageRoot, "dist/index.js"), "utf8"),
|
||||
"export default {};\n",
|
||||
);
|
||||
const versionDirectory = path.dirname(packageRoot);
|
||||
const retainedArchive = path.join(versionDirectory, ARCHIVE_SNAPSHOT_FILE);
|
||||
assert.equal((await fs.promises.stat(retainedArchive)).isFile(), true);
|
||||
assert.equal(
|
||||
createHash("sha256").update(await fs.promises.readFile(retainedArchive)).digest("hex"),
|
||||
installed.archiveSha256,
|
||||
);
|
||||
const installMetadata = JSON.parse(await fs.promises.readFile(
|
||||
path.join(versionDirectory, "install.json"),
|
||||
"utf8",
|
||||
));
|
||||
assert.match(installMetadata.contentSha256, /^[a-f0-9]{64}$/u);
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
});
|
||||
|
||||
test("runtime preparation rejects changed files and disables the active plugin", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const packageRoot = fixture.store.resolvePackageRoot(installed);
|
||||
await fs.promises.writeFile(path.join(packageRoot, "dist/index.js"), "export default { tampered: true };\n");
|
||||
|
||||
await assert.rejects(
|
||||
fixture.store.preparePackageRoot(installed),
|
||||
/do not match the retained package archive/,
|
||||
);
|
||||
const failed = fixture.database.getActivePlugin(installed.id);
|
||||
assert.equal(failed.enabled, false);
|
||||
assert.equal(failed.runtime.status, "error");
|
||||
assert.match(failed.runtime.lastError, /integrity check failed/);
|
||||
});
|
||||
|
||||
test("runtime preparation rejects unpackaged root entries hidden from source builds", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const packageRoot = fixture.store.resolvePackageRoot(installed);
|
||||
await fs.promises.mkdir(path.join(packageRoot, "node_modules", "injected"), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(packageRoot, "node_modules", "injected", "index.js"), "throw 1;\n");
|
||||
|
||||
await assert.rejects(
|
||||
fixture.store.preparePackageRoot(installed),
|
||||
/unpackaged root entry: node_modules/,
|
||||
);
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
|
||||
});
|
||||
|
||||
test("startup keeps a corrupted committed snapshot for diagnosis and fails closed", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const versionDirectory = path.dirname(fixture.store.resolvePackageRoot(installed));
|
||||
const retainedArchive = path.join(versionDirectory, ARCHIVE_SNAPSHOT_FILE);
|
||||
await fs.promises.writeFile(retainedArchive, "corrupt archive");
|
||||
|
||||
await fixture.store.recover();
|
||||
|
||||
assert.equal((await fs.promises.stat(retainedArchive)).isFile(), true);
|
||||
const failed = fixture.database.getActivePlugin(installed.id);
|
||||
assert.equal(failed.enabled, false);
|
||||
assert.equal(failed.runtime.status, "error");
|
||||
assert.match(failed.runtime.lastError, /integrity check failed/);
|
||||
});
|
||||
|
||||
test("reinstalling identical bytes repairs a corrupted retained snapshot", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const versionDirectory = path.dirname(fixture.store.resolvePackageRoot(installed));
|
||||
const retainedArchive = path.join(versionDirectory, ARCHIVE_SNAPSHOT_FILE);
|
||||
await fs.promises.writeFile(retainedArchive, "corrupt archive");
|
||||
|
||||
const repaired = await fixture.store.install(pluginPackage.archive);
|
||||
|
||||
assert.equal(repaired.enabled, true);
|
||||
assert.equal(
|
||||
createHash("sha256").update(await fs.promises.readFile(retainedArchive)).digest("hex"),
|
||||
repaired.archiveSha256,
|
||||
);
|
||||
assert.equal(await fixture.store.preparePackageRoot(repaired), path.join(versionDirectory, "package"));
|
||||
});
|
||||
|
||||
test("package install is idempotent for identical bytes and rejects version substitution", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const first = await createPackage(fixture.root);
|
||||
await fixture.store.install(first.archive);
|
||||
const secondVersion = await createPackage(fixture.root, {
|
||||
manifest: { version: "2.0.0" },
|
||||
source: "export default { version: 2 };\n",
|
||||
});
|
||||
await fixture.store.install(secondVersion.archive);
|
||||
assert.equal(fixture.database.getActivePlugin(first.manifest.id).activeVersion, "2.0.0");
|
||||
const reactivated = await fixture.store.install(first.archive, { enable: true });
|
||||
assert.equal(reactivated.activeVersion, "1.0.0");
|
||||
assert.equal(reactivated.enabled, true);
|
||||
|
||||
const changed = await createPackage(fixture.root, { source: "export default { changed: true };\n" });
|
||||
await assert.rejects(
|
||||
fixture.store.install(changed.archive),
|
||||
/already installed with different contents/,
|
||||
);
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
});
|
||||
|
||||
test("package activation hook runs before the active version changes", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const first = await createPackage(fixture.root);
|
||||
const second = await createPackage(fixture.root, {
|
||||
manifest: { version: "2.0.0" },
|
||||
source: "export default { version: 2 };\n",
|
||||
});
|
||||
await fixture.store.install(first.archive, { enable: true });
|
||||
const observations = [];
|
||||
|
||||
const installed = await fixture.store.install(second.archive, {
|
||||
beforeActivate(details) {
|
||||
observations.push({
|
||||
details,
|
||||
activeVersion: fixture.database.getActivePlugin(first.manifest.id).activeVersion,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(installed.activeVersion, "2.0.0");
|
||||
assert.equal(installed.enabled, true);
|
||||
assert.equal(observations.length, 1);
|
||||
assert.equal(observations[0].activeVersion, "1.0.0");
|
||||
assert.equal(observations[0].details.pluginId, first.manifest.id);
|
||||
assert.equal(observations[0].details.version, "2.0.0");
|
||||
assert.equal(observations[0].details.previousPlugin.activeVersion, "1.0.0");
|
||||
assert.equal(observations[0].details.reason, "switch-active-version");
|
||||
});
|
||||
|
||||
test("failed activation preparation preserves the previous database selection", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const first = await createPackage(fixture.root);
|
||||
const second = await createPackage(fixture.root, { manifest: { version: "2.0.0" } });
|
||||
await fixture.store.install(first.archive, { enable: true });
|
||||
|
||||
await assert.rejects(
|
||||
fixture.store.install(second.archive, {
|
||||
beforeActivate() { throw new Error("runtime did not stop"); },
|
||||
}),
|
||||
/runtime did not stop/,
|
||||
);
|
||||
|
||||
const active = fixture.database.getActivePlugin(first.manifest.id);
|
||||
assert.equal(active.activeVersion, "1.0.0");
|
||||
assert.equal(active.enabled, true);
|
||||
assert.equal(fixture.database.getVersion(first.manifest.id, "2.0.0"), null);
|
||||
await assert.rejects(
|
||||
fs.promises.stat(path.join(fixture.paths.packages, first.manifest.id, "2.0.0")),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
});
|
||||
|
||||
test("startup recovery removes staging debris and reconstructs a committed orphan disabled", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
await fs.promises.mkdir(path.join(fixture.paths.staging, "partial"));
|
||||
fixture.database.removePlugin(installed.id);
|
||||
|
||||
await fixture.store.recover();
|
||||
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
const recovered = fixture.database.getActivePlugin(installed.id);
|
||||
assert.equal(recovered.activeVersion, "1.0.0");
|
||||
assert.equal(recovered.enabled, false);
|
||||
});
|
||||
|
||||
test("startup recovery force-disables a newer orphan when an older version was enabled", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
const donor = createStore(context);
|
||||
await Promise.all([fixture.store.initialize(), donor.store.initialize()]);
|
||||
const first = await createPackage(fixture.root);
|
||||
const second = await createPackage(donor.root, { manifest: { version: "2.0.0" } });
|
||||
await fixture.store.install(first.archive, { enable: true });
|
||||
await donor.store.install(second.archive);
|
||||
|
||||
const orphanSource = path.join(donor.paths.packages, first.manifest.id, "2.0.0");
|
||||
const orphanTarget = path.join(fixture.paths.packages, first.manifest.id, "2.0.0");
|
||||
await fs.promises.cp(orphanSource, orphanTarget, { recursive: true, errorOnExist: true });
|
||||
|
||||
assert.equal(fixture.database.getActivePlugin(first.manifest.id).enabled, true);
|
||||
await fixture.store.recover();
|
||||
|
||||
const recovered = fixture.database.getActivePlugin(first.manifest.id);
|
||||
assert.equal(recovered.activeVersion, "2.0.0");
|
||||
assert.equal(recovered.enabled, false);
|
||||
});
|
||||
|
||||
test("startup recovery discards incomplete removal staging before a package was moved", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const emptyRemoval = path.join(fixture.paths.staging, "remove-empty");
|
||||
const partialRemoval = path.join(fixture.paths.staging, "remove-partial");
|
||||
await fs.promises.mkdir(emptyRemoval);
|
||||
await fs.promises.mkdir(partialRemoval);
|
||||
await fs.promises.writeFile(path.join(partialRemoval, REMOVAL_METADATA_FILE), "{\"pluginId\":");
|
||||
|
||||
await fixture.store.recover();
|
||||
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
});
|
||||
|
||||
test("startup recovery keeps an unidentified moved package fail-closed", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const removalDirectory = path.join(fixture.paths.staging, "remove-corrupt-metadata");
|
||||
const removedPluginPath = path.join(removalDirectory, REMOVED_PLUGIN_DIRECTORY);
|
||||
await fs.promises.mkdir(removalDirectory);
|
||||
await fs.promises.rename(path.join(fixture.paths.packages, installed.id), removedPluginPath);
|
||||
await fs.promises.writeFile(path.join(removalDirectory, REMOVAL_METADATA_FILE), "{\"pluginId\":");
|
||||
|
||||
await assert.rejects(fixture.store.recover());
|
||||
|
||||
assert.equal((await fs.promises.stat(removedPluginPath)).isDirectory(), true);
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, true);
|
||||
});
|
||||
|
||||
test("startup recovery completes or rolls back an interrupted uninstall", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
const installedPluginPath = path.join(fixture.paths.packages, installed.id);
|
||||
|
||||
const stageRemoval = async (name) => {
|
||||
const removalDirectory = path.join(fixture.paths.staging, name);
|
||||
await fs.promises.mkdir(removalDirectory, { mode: 0o700 });
|
||||
await fs.promises.writeFile(
|
||||
path.join(removalDirectory, REMOVAL_METADATA_FILE),
|
||||
`${JSON.stringify({ pluginId: installed.id })}\n`,
|
||||
);
|
||||
await fs.promises.rename(
|
||||
installedPluginPath,
|
||||
path.join(removalDirectory, REMOVED_PLUGIN_DIRECTORY),
|
||||
);
|
||||
return removalDirectory;
|
||||
};
|
||||
|
||||
const rollbackDirectory = await stageRemoval("remove-before-database");
|
||||
await fixture.store.recover();
|
||||
assert.equal((await fs.promises.stat(installedPluginPath)).isDirectory(), true);
|
||||
await assert.rejects(fs.promises.stat(rollbackDirectory), { code: "ENOENT" });
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).activeVersion, "1.0.0");
|
||||
|
||||
const completionDirectory = await stageRemoval("remove-after-database");
|
||||
fixture.database.removePlugin(installed.id);
|
||||
await fixture.store.recover();
|
||||
await assert.rejects(fs.promises.stat(completionDirectory), { code: "ENOENT" });
|
||||
await assert.rejects(fs.promises.stat(installedPluginPath), { code: "ENOENT" });
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id), null);
|
||||
});
|
||||
|
||||
test("uninstall syncs the package-store source before deleting its database row", async (context) => {
|
||||
const synced = [];
|
||||
const fixture = createStore(context, {
|
||||
async syncDirectory(directory) { synced.push(directory); },
|
||||
});
|
||||
await fixture.store.initialize();
|
||||
const pluginPackage = await createPackage(fixture.root);
|
||||
const installed = await fixture.store.install(pluginPackage.archive, { enable: true });
|
||||
synced.length = 0;
|
||||
const removePlugin = fixture.database.removePlugin.bind(fixture.database);
|
||||
fixture.database.removePlugin = (pluginId) => {
|
||||
assert.equal(pluginId, installed.id);
|
||||
assert.equal(synced.at(-1), fixture.paths.packages);
|
||||
removePlugin(pluginId);
|
||||
};
|
||||
|
||||
assert.equal(await fixture.store.uninstall(installed.id), true);
|
||||
assert.equal(synced.includes(fixture.paths.packages), true);
|
||||
});
|
||||
|
||||
test("failed package validation removes every extracted staging file", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const invalid = path.join(fixture.root, "invalid.ncpkg");
|
||||
await fs.promises.writeFile(invalid, "not a zip");
|
||||
|
||||
await assert.rejects(fixture.store.install(invalid));
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
assert.deepEqual(fixture.database.listPlugins(), []);
|
||||
});
|
||||
41
electron/plugins/packaging.test.cjs
Normal file
41
electron/plugins/packaging.test.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
test("packaged application declares every plugin host runtime dependency and resource", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
||||
for (const dependency of [
|
||||
"@netcatty/plugin-cli",
|
||||
"@netcatty/plugin-contract",
|
||||
"@netcatty/plugin-sdk",
|
||||
]) {
|
||||
assert.equal(packageJson.dependencies[dependency], "0.1.0-internal");
|
||||
}
|
||||
const builderSource = fs.readFileSync(path.join(__dirname, "../../electron-builder.config.cjs"), "utf8");
|
||||
assert.match(builderSource, /node_modules\/@netcatty\/plugin-cli\/\*\*\/\*/);
|
||||
assert.match(builderSource, /node_modules\/@netcatty\/plugin-contract\/\*\*\/\*/);
|
||||
assert.match(builderSource, /node_modules\/@netcatty\/plugin-sdk\/\*\*\/\*/);
|
||||
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
|
||||
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
|
||||
});
|
||||
|
||||
test("clean-checkout test entrypoints build plugin runtime workspaces first", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
||||
assert.equal(
|
||||
packageJson.scripts["build:plugin-runtime-deps"],
|
||||
"npm run build --workspace @netcatty/plugin-contract"
|
||||
+ " && npm run build --workspace @netcatty/plugin-sdk"
|
||||
+ " && npm run build --workspace @netcatty/plugin-cli",
|
||||
);
|
||||
// main may append check:codebuddy-sdk-contract after build:plugin-runtime-deps.
|
||||
assert.match(
|
||||
packageJson.scripts.pretest,
|
||||
/^npm run build:plugin-runtime-deps(?: && npm run check:codebuddy-sdk-contract)?$/,
|
||||
);
|
||||
for (const lifecycle of ["pretest:plugin-runtime", "pretest:plugin-runtime:electron"]) {
|
||||
assert.equal(packageJson.scripts[lifecycle], "npm run build:plugin-runtime-deps");
|
||||
}
|
||||
});
|
||||
50
electron/plugins/paths.cjs
Normal file
50
electron/plugins/paths.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
function createPluginPaths(userDataDirectory) {
|
||||
if (typeof userDataDirectory !== "string" || !path.isAbsolute(userDataDirectory)) {
|
||||
throw new TypeError("Plugin userData directory must be absolute");
|
||||
}
|
||||
const root = path.join(userDataDirectory, "plugins");
|
||||
return Object.freeze({
|
||||
root,
|
||||
database: path.join(root, "plugins.sqlite"),
|
||||
packages: path.join(root, "packages"),
|
||||
staging: path.join(root, "staging"),
|
||||
data: path.join(root, "data"),
|
||||
logs: path.join(root, "logs"),
|
||||
dev: path.join(root, "dev"),
|
||||
});
|
||||
}
|
||||
|
||||
function assertPluginStorageSegment(value, kind) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 128) {
|
||||
throw new TypeError(`Invalid plugin ${kind}`);
|
||||
}
|
||||
if (value === "." || value === ".." || /[\\/\0]/u.test(value)) {
|
||||
throw new TypeError(`Unsafe plugin ${kind}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveInstalledVersionDirectory(paths, pluginId, version) {
|
||||
return path.join(
|
||||
paths.packages,
|
||||
assertPluginStorageSegment(pluginId, "ID"),
|
||||
assertPluginStorageSegment(version, "version"),
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInside(parentPath, candidatePath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPluginStorageSegment,
|
||||
createPluginPaths,
|
||||
isPathInside,
|
||||
resolveInstalledVersionDirectory,
|
||||
};
|
||||
30
electron/plugins/paths.test.cjs
Normal file
30
electron/plugins/paths.test.cjs
Normal file
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
assertPluginStorageSegment,
|
||||
createPluginPaths,
|
||||
isPathInside,
|
||||
resolveInstalledVersionDirectory,
|
||||
} = require("./paths.cjs");
|
||||
|
||||
test("plugin paths remain rooted under userData", () => {
|
||||
const paths = createPluginPaths(path.resolve("/tmp/netcatty-user-data"));
|
||||
assert.equal(paths.database, path.join(paths.root, "plugins.sqlite"));
|
||||
assert.equal(
|
||||
resolveInstalledVersionDirectory(paths, "com.example.test", "1.0.0"),
|
||||
path.join(paths.packages, "com.example.test", "1.0.0"),
|
||||
);
|
||||
assert.equal(isPathInside(paths.packages, path.join(paths.packages, "child")), true);
|
||||
assert.equal(isPathInside(paths.packages, path.join(paths.root, "escape")), false);
|
||||
});
|
||||
|
||||
test("plugin storage segments reject path syntax", () => {
|
||||
for (const value of ["", ".", "..", "a/b", "a\\b", "a\0b"]) {
|
||||
assert.throws(() => assertPluginStorageSegment(value, "test"));
|
||||
}
|
||||
assert.equal(assertPluginStorageSegment("com.example.valid", "test"), "com.example.valid");
|
||||
});
|
||||
755
electron/plugins/permissionEngine.cjs
Normal file
755
electron/plugins/permissionEngine.cjs
Normal file
@@ -0,0 +1,755 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
assertPluginPermission,
|
||||
canonicalizePermissionResource,
|
||||
declarationAllowsResource,
|
||||
defaultSecurityPrincipal,
|
||||
normalizePermissionDeclarations,
|
||||
permissionDeclarationHash,
|
||||
permissionResourceCovers,
|
||||
} = require("./permissionResources.cjs");
|
||||
|
||||
const VALID_SCOPES = new Set(["once", "session", "application", "always"]);
|
||||
const AUDITED_PERMISSION_USES = new Set([
|
||||
"runtime.advanced",
|
||||
"network",
|
||||
"filesystem.read",
|
||||
"filesystem.write",
|
||||
"secrets",
|
||||
"companion.execute",
|
||||
"vault.credentials",
|
||||
"terminal.input",
|
||||
"terminal.intercept.input",
|
||||
"terminal.intercept.output",
|
||||
]);
|
||||
const PROMPT_TIMEOUT_MS = 30_000;
|
||||
const PERMISSION_GRANT_SCOPES = new Set(["once", "session", "application", "always"]);
|
||||
const HOST_PERMISSION_REASONS = Object.freeze({
|
||||
"terminal.intercept.input": "This plugin can inspect and rewrite Terminal input. Netcatty bypasses host-recognized credential input, but arbitrary no-echo input may not be detectable.",
|
||||
});
|
||||
|
||||
function normalizePermissionOperationId(value) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string" || value.length < 1 || value.includes("\0")) {
|
||||
throw permissionDenied("Plugin permission operation ID is invalid");
|
||||
}
|
||||
if (value.length <= 128) return value;
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function normalizePermissionReason(value, permission) {
|
||||
const reason = typeof value === "string" && value.length > 0
|
||||
? value
|
||||
: `Allow ${permission}`;
|
||||
if (reason.length <= 1_024) return reason;
|
||||
const suffix = ` [sha256:${createHash("sha256").update(reason).digest("hex")}]`;
|
||||
return `${reason.slice(0, 1_024 - suffix.length)}${suffix}`;
|
||||
}
|
||||
|
||||
function normalizePermissionSessionId(value) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 128 || value.includes("\0")) {
|
||||
throw permissionDenied("Plugin permission session ID is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePermissionAllowedScopes(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw permissionDenied("Plugin permission grant scopes are invalid");
|
||||
}
|
||||
const scopes = [...new Set(value)];
|
||||
if (scopes.some((scope) => !PERMISSION_GRANT_SCOPES.has(scope))) {
|
||||
throw permissionDenied("Plugin permission grant scopes are invalid");
|
||||
}
|
||||
return Object.freeze(scopes);
|
||||
}
|
||||
|
||||
function normalizePermissionResources(permission, values, label = "Plugin permission resources") {
|
||||
return normalizePermissionResourceDescriptors(permission, values, undefined, label)
|
||||
.map(({ resource }) => resource);
|
||||
}
|
||||
|
||||
function normalizePermissionResourceDescriptors(
|
||||
permission,
|
||||
values,
|
||||
resourceKinds,
|
||||
label = "Plugin permission resources",
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
resourceKinds !== undefined
|
||||
&& (!Array.isArray(resourceKinds) || resourceKinds.length !== values.length)
|
||||
) throw new TypeError("Resource kinds must align with resources");
|
||||
const descriptors = new Map();
|
||||
for (const [index, value] of values.entries()) {
|
||||
const resource = canonicalizePermissionResource(permission, value);
|
||||
const resourceKind = resourceKinds?.[index] ?? "exact";
|
||||
if (
|
||||
(resourceKind !== "exact" && resourceKind !== "directory")
|
||||
|| (resourceKind === "directory"
|
||||
&& permission !== "filesystem.read"
|
||||
&& permission !== "filesystem.write")
|
||||
|| (resource === "*" && resourceKind !== "exact")
|
||||
) throw new TypeError("Resource kind is invalid");
|
||||
const previous = descriptors.get(resource);
|
||||
if (previous && previous.resourceKind !== resourceKind) {
|
||||
throw new TypeError("Duplicate resources cannot have conflicting kinds");
|
||||
}
|
||||
descriptors.set(resource, Object.freeze({ resource, resourceKind }));
|
||||
}
|
||||
return [...descriptors.values()].sort((left, right) => (
|
||||
left.resource.localeCompare(right.resource, "en")
|
||||
));
|
||||
} catch {
|
||||
throw permissionDenied(`${label} are invalid`, { permission });
|
||||
}
|
||||
}
|
||||
|
||||
function permissionDenied(message, details) {
|
||||
return new PluginRpcError(RPC_ERRORS.permissionDenied, message, {
|
||||
pluginCode: "permission_denied",
|
||||
...(details === undefined ? {} : { details }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDecision(decision, requestId) {
|
||||
if (!decision || typeof decision !== "object" || Array.isArray(decision)) {
|
||||
throw permissionDenied("Plugin permission request was not approved");
|
||||
}
|
||||
if (decision.requestId !== requestId) {
|
||||
throw permissionDenied("Plugin permission decision does not match the pending request");
|
||||
}
|
||||
if (decision.decision === "deny" || decision.decision === "cancel") {
|
||||
if (Object.keys(decision).some((key) => key !== "requestId" && key !== "decision")) {
|
||||
throw permissionDenied("Plugin permission decision is invalid");
|
||||
}
|
||||
return Object.freeze({ requestId, decision: decision.decision });
|
||||
}
|
||||
if (decision.decision !== "allow" || !VALID_SCOPES.has(decision.scope)) {
|
||||
throw permissionDenied("Plugin permission decision is invalid");
|
||||
}
|
||||
if (Object.keys(decision).some((key) => (
|
||||
key !== "requestId" && key !== "decision" && key !== "scope" && key !== "resources"
|
||||
))) throw permissionDenied("Plugin permission decision is invalid");
|
||||
if (
|
||||
decision.resources !== undefined
|
||||
&& (!Array.isArray(decision.resources) || decision.resources.length > 128)
|
||||
) throw permissionDenied("Plugin permission decision resources are invalid");
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
decision: "allow",
|
||||
scope: decision.scope,
|
||||
...(decision.resources === undefined
|
||||
? {}
|
||||
: { resources: Object.freeze([...decision.resources]) }),
|
||||
});
|
||||
}
|
||||
|
||||
function immutableRequest(request) {
|
||||
return Object.freeze({
|
||||
...request,
|
||||
resources: Object.freeze([...request.resources]),
|
||||
resourceKinds: Object.freeze([...request.resourceKinds]),
|
||||
});
|
||||
}
|
||||
|
||||
class PluginPermissionEngine {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.requestDecision = options.requestDecision ?? null;
|
||||
if (this.requestDecision != null && typeof this.requestDecision !== "function") {
|
||||
throw new TypeError("Plugin permission decision provider must be a function");
|
||||
}
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
this.promptTimeoutMs = options.promptTimeoutMs ?? PROMPT_TIMEOUT_MS;
|
||||
this.applicationGrants = new Map();
|
||||
this.sessionGrants = new Map();
|
||||
this.pending = new Map();
|
||||
this.sessionPromptControllers = new Map();
|
||||
this.promptControllers = new Set();
|
||||
this.revocationListeners = new Set();
|
||||
this.closed = false;
|
||||
this.shutdownController = new AbortController();
|
||||
}
|
||||
|
||||
onDidRevoke(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Permission revocation listener is required");
|
||||
this.revocationListeners.add(listener);
|
||||
return Object.freeze({ dispose: () => this.revocationListeners.delete(listener) });
|
||||
}
|
||||
|
||||
#emitRevocation(event) {
|
||||
const frozen = Object.freeze({ ...event });
|
||||
for (const listener of this.revocationListeners) {
|
||||
try { listener(frozen); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
#abortPrompts(pluginId, permission, resource) {
|
||||
for (const entry of this.promptControllers) {
|
||||
if (entry.pluginId !== pluginId) continue;
|
||||
if (permission !== undefined && entry.permission !== permission) continue;
|
||||
if (resource !== undefined && !entry.resources.includes(resource)) continue;
|
||||
entry.controller.abort(permissionDenied("Plugin permission was revoked"));
|
||||
}
|
||||
}
|
||||
|
||||
#grantKey(pluginId, permission, declarationHash, resource) {
|
||||
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||||
}
|
||||
|
||||
#sessionGrantKey(sessionId, grantKey) {
|
||||
return `${sessionId}\0${grantKey}`;
|
||||
}
|
||||
|
||||
#createMemoryGrant(
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
scope,
|
||||
sessionId,
|
||||
) {
|
||||
return Object.freeze({
|
||||
pluginId: context.pluginId,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
scope,
|
||||
sessionId: sessionId ?? null,
|
||||
grantedAt: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
#memoryGrantCovers(
|
||||
grant,
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
sessionId,
|
||||
) {
|
||||
return grant.pluginId === context.pluginId
|
||||
&& grant.permission === permission
|
||||
&& grant.declarationHash === declarationHash
|
||||
&& (grant.scope !== "session" || grant.sessionId === sessionId)
|
||||
&& permissionResourceCovers(
|
||||
permission,
|
||||
grant.resource,
|
||||
resource,
|
||||
grant.resourceKind,
|
||||
resourceKind,
|
||||
);
|
||||
}
|
||||
|
||||
#assertDeclared(context, permission, resources) {
|
||||
const declarations = normalizePermissionDeclarations(context.manifest);
|
||||
const declaration = declarations.get(permission);
|
||||
if (!declaration) {
|
||||
throw permissionDenied(`Plugin did not declare permission: ${permission}`, { permission });
|
||||
}
|
||||
for (const resource of resources) {
|
||||
if (!declarationAllowsResource(declaration, resource)) {
|
||||
throw permissionDenied(`Plugin did not declare permission resource: ${permission}`, {
|
||||
permission,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
const securityPrincipal = context.securityPrincipal ?? defaultSecurityPrincipal(context.manifest);
|
||||
return {
|
||||
declaration,
|
||||
declarationHash: permissionDeclarationHash(declaration, securityPrincipal),
|
||||
securityPrincipal,
|
||||
};
|
||||
}
|
||||
|
||||
#hasGrant(context, permission, declarationHash, resource, resourceKind, sessionId) {
|
||||
if ([...this.applicationGrants.values()].some((grant) => (
|
||||
this.#memoryGrantCovers(
|
||||
grant,
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
sessionId,
|
||||
)
|
||||
))) return true;
|
||||
if (sessionId && [...this.sessionGrants.values()].some((grant) => (
|
||||
this.#memoryGrantCovers(
|
||||
grant,
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
sessionId,
|
||||
)
|
||||
))) return true;
|
||||
return this.database.listPermissionGrants(context.pluginId).some((grant) => (
|
||||
grant.permission === permission
|
||||
&& grant.declarationHash === declarationHash
|
||||
&& permissionResourceCovers(
|
||||
permission,
|
||||
grant.resource,
|
||||
resource,
|
||||
grant.resourceKind,
|
||||
resourceKind,
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#hasAllGrants(context, permission, declarationHash, resources, resourceKinds, sessionId) {
|
||||
return resources.every((resource, index) => this.#hasGrant(
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKinds[index],
|
||||
sessionId,
|
||||
));
|
||||
}
|
||||
|
||||
async authorize(context, descriptor) {
|
||||
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
|
||||
context.signal?.throwIfAborted();
|
||||
const permission = descriptor.permission;
|
||||
if (
|
||||
descriptor.resources !== undefined
|
||||
&& (!Array.isArray(descriptor.resources) || descriptor.resources.length > 128)
|
||||
) throw permissionDenied("Plugin permission resources are invalid");
|
||||
if (
|
||||
descriptor.resourceKinds !== undefined
|
||||
&& (
|
||||
!Array.isArray(descriptor.resourceKinds)
|
||||
|| descriptor.resourceKinds.length > 128
|
||||
|| !Array.isArray(descriptor.resources)
|
||||
|| descriptor.resourceKinds.length !== descriptor.resources.length
|
||||
)
|
||||
) throw permissionDenied("Plugin permission resource kinds are invalid");
|
||||
descriptor = Object.freeze({
|
||||
...descriptor,
|
||||
reason: normalizePermissionReason(
|
||||
HOST_PERMISSION_REASONS[permission] ?? descriptor.reason,
|
||||
permission,
|
||||
),
|
||||
operationId: normalizePermissionOperationId(descriptor.operationId),
|
||||
sessionId: normalizePermissionSessionId(descriptor.sessionId),
|
||||
allowedScopes: normalizePermissionAllowedScopes(descriptor.allowedScopes),
|
||||
});
|
||||
const resourceDescriptors = normalizePermissionResourceDescriptors(
|
||||
permission,
|
||||
descriptor.resources?.length ? descriptor.resources : ["*"],
|
||||
descriptor.resources?.length ? descriptor.resourceKinds : undefined,
|
||||
);
|
||||
const resources = resourceDescriptors.map(({ resource }) => resource);
|
||||
const resourceKinds = resourceDescriptors.map(({ resourceKind }) => resourceKind);
|
||||
const { declaration, declarationHash } = this.#assertDeclared(context, permission, resources);
|
||||
if (this.#hasAllGrants(
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
resources,
|
||||
resourceKinds,
|
||||
descriptor.sessionId,
|
||||
)) {
|
||||
if (AUDITED_PERMISSION_USES.has(permission)) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.used", {
|
||||
permission,
|
||||
resources,
|
||||
resourceKinds,
|
||||
runtimeId: context.runtimeId ?? null,
|
||||
operationId: descriptor.operationId ?? null,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ declaration, resources, scope: "existing" });
|
||||
}
|
||||
if (descriptor.interactive === false || !this.requestDecision) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||||
permission,
|
||||
resources,
|
||||
reason: "no-interactive-approver",
|
||||
});
|
||||
throw permissionDenied(`Plugin permission is not granted: ${permission}`, {
|
||||
permission,
|
||||
resources,
|
||||
});
|
||||
}
|
||||
const pendingKey = JSON.stringify([
|
||||
context.pluginId,
|
||||
context.runtimeId ?? null,
|
||||
permission,
|
||||
declarationHash,
|
||||
resources,
|
||||
resourceKinds,
|
||||
descriptor.operationId ?? null,
|
||||
descriptor.sessionId ?? null,
|
||||
descriptor.allowedScopes ?? null,
|
||||
]);
|
||||
let pending = this.pending.get(pendingKey);
|
||||
let ownsPrompt = false;
|
||||
if (!pending) {
|
||||
ownsPrompt = true;
|
||||
pending = this.#requestGrant(
|
||||
context,
|
||||
descriptor,
|
||||
resources,
|
||||
resourceKinds,
|
||||
declaration,
|
||||
declarationHash,
|
||||
)
|
||||
.finally(() => this.pending.delete(pendingKey));
|
||||
this.pending.set(pendingKey, pending);
|
||||
}
|
||||
const grant = await pending;
|
||||
context.signal?.throwIfAborted();
|
||||
if (!ownsPrompt && grant.scope === "once") {
|
||||
return this.authorize(context, descriptor);
|
||||
}
|
||||
return Object.freeze({ declaration, resources, scope: grant.scope });
|
||||
}
|
||||
|
||||
async #requestGrant(
|
||||
context,
|
||||
descriptor,
|
||||
resources,
|
||||
resourceKinds,
|
||||
declaration,
|
||||
declarationHash,
|
||||
) {
|
||||
const requestId = randomUUID();
|
||||
const request = immutableRequest({
|
||||
requestId,
|
||||
pluginId: context.pluginId,
|
||||
...(context.pluginVersion === undefined ? {} : { pluginVersion: context.pluginVersion }),
|
||||
...(context.manifest?.name === undefined ? {} : { pluginName: context.manifest.name }),
|
||||
...(context.manifest?.publisher === undefined ? {} : { publisher: context.manifest.publisher }),
|
||||
runtimeId: context.runtimeId ?? null,
|
||||
runtimeKind: context.runtimeKind ?? null,
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
resourceKinds,
|
||||
reason: descriptor.reason,
|
||||
...(descriptor.operationId === undefined ? {} : { operationId: descriptor.operationId }),
|
||||
...(descriptor.sessionId === undefined ? {} : { sessionId: descriptor.sessionId }),
|
||||
...(descriptor.allowedScopes === undefined ? {} : { allowedScopes: descriptor.allowedScopes }),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const promptEntry = Object.freeze({
|
||||
controller,
|
||||
pluginId: context.pluginId,
|
||||
permission: descriptor.permission,
|
||||
resources: Object.freeze([...resources]),
|
||||
});
|
||||
this.promptControllers.add(promptEntry);
|
||||
if (descriptor.sessionId) {
|
||||
const controllers = this.sessionPromptControllers.get(descriptor.sessionId) ?? new Set();
|
||||
controllers.add(controller);
|
||||
this.sessionPromptControllers.set(descriptor.sessionId, controllers);
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(permissionDenied(
|
||||
"Plugin permission request timed out",
|
||||
)), this.promptTimeoutMs);
|
||||
timer.unref?.();
|
||||
const onAbort = () => controller.abort(context.signal.reason);
|
||||
const onShutdown = () => controller.abort(this.shutdownController.signal.reason);
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.shutdownController.signal.addEventListener("abort", onShutdown, { once: true });
|
||||
let rawDecision;
|
||||
try {
|
||||
rawDecision = await raceWithAbort(
|
||||
Promise.resolve(this.requestDecision(request, { signal: controller.signal })),
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
reason: controller.signal.aborted ? "prompt-aborted" : "decision-provider-failed",
|
||||
});
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
throw permissionDenied("Plugin permission decision provider failed", {
|
||||
permission: descriptor.permission,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.promptControllers.delete(promptEntry);
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
this.shutdownController.signal.removeEventListener("abort", onShutdown);
|
||||
if (descriptor.sessionId) {
|
||||
const controllers = this.sessionPromptControllers.get(descriptor.sessionId);
|
||||
controllers?.delete(controller);
|
||||
if (controllers?.size === 0) this.sessionPromptControllers.delete(descriptor.sessionId);
|
||||
}
|
||||
}
|
||||
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
|
||||
let decision;
|
||||
try {
|
||||
decision = normalizeDecision(rawDecision, requestId);
|
||||
} catch (error) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
reason: "invalid-decision",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (decision.decision !== "allow") {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
decision: decision.decision,
|
||||
});
|
||||
const outcome = decision.decision === "deny" ? "denied" : "cancelled";
|
||||
throw permissionDenied(`Plugin permission was ${outcome}: ${descriptor.permission}`);
|
||||
}
|
||||
if (descriptor.allowedScopes && !descriptor.allowedScopes.includes(decision.scope)) {
|
||||
throw permissionDenied(`Plugin permission scope is not supported for this operation: ${decision.scope}`);
|
||||
}
|
||||
let decisionResources;
|
||||
try {
|
||||
decisionResources = normalizePermissionResources(
|
||||
descriptor.permission,
|
||||
decision.resources === undefined ? resources : decision.resources,
|
||||
"Plugin permission decision resources",
|
||||
);
|
||||
} catch (error) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
reason: "invalid-decision-resources",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!decisionResources.every((resource) => declarationAllowsResource(declaration, resource))) {
|
||||
throw permissionDenied("Plugin permission decision exceeds the manifest declaration");
|
||||
}
|
||||
const decisionResourceDescriptors = decisionResources.map((resource) => {
|
||||
const requestedIndex = resources.indexOf(resource);
|
||||
return Object.freeze({
|
||||
resource,
|
||||
resourceKind: requestedIndex === -1 ? "exact" : resourceKinds[requestedIndex],
|
||||
});
|
||||
});
|
||||
if (!resources.every((requested) => decisionResourceDescriptors.some((granted) => (
|
||||
permissionResourceCovers(
|
||||
descriptor.permission,
|
||||
granted.resource,
|
||||
requested,
|
||||
granted.resourceKind,
|
||||
resourceKinds[resources.indexOf(requested)],
|
||||
)
|
||||
)))) {
|
||||
throw permissionDenied("Plugin permission decision does not cover the requested resources");
|
||||
}
|
||||
if (decision.scope === "session" && !descriptor.sessionId) {
|
||||
throw permissionDenied("Session-scoped plugin permission requires a host-owned session");
|
||||
}
|
||||
context.signal?.throwIfAborted();
|
||||
descriptor.validateBeforeGrant?.();
|
||||
for (const { resource, resourceKind } of decisionResourceDescriptors) {
|
||||
const grantKey = this.#grantKey(
|
||||
context.pluginId,
|
||||
descriptor.permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
);
|
||||
if (decision.scope === "application") {
|
||||
this.applicationGrants.set(grantKey, this.#createMemoryGrant(
|
||||
context,
|
||||
descriptor.permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
decision.scope,
|
||||
));
|
||||
}
|
||||
if (decision.scope === "session") {
|
||||
this.sessionGrants.set(
|
||||
this.#sessionGrantKey(descriptor.sessionId, grantKey),
|
||||
this.#createMemoryGrant(
|
||||
context,
|
||||
descriptor.permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
resourceKind,
|
||||
decision.scope,
|
||||
descriptor.sessionId,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (decision.scope === "always") {
|
||||
this.database.upsertPermissionGrant({
|
||||
pluginId: context.pluginId,
|
||||
permission: descriptor.permission,
|
||||
resource,
|
||||
resourceKind,
|
||||
declarationHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.granted", {
|
||||
permission: descriptor.permission,
|
||||
resources: decisionResources,
|
||||
resourceKinds: decisionResourceDescriptors.map(({ resourceKind }) => resourceKind),
|
||||
scope: decision.scope,
|
||||
operationId: descriptor.operationId ?? null,
|
||||
});
|
||||
return decision;
|
||||
}
|
||||
|
||||
async authorizeRequired(plugin, options = {}) {
|
||||
const declarations = normalizePermissionDeclarations(plugin.manifest);
|
||||
const skipPermissions = new Set(options.skipPermissions ?? []);
|
||||
const context = {
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
runtimeId: options.runtimeId ?? null,
|
||||
manifest: plugin.manifest,
|
||||
securityPrincipal: options.securityPrincipal,
|
||||
signal: options.signal,
|
||||
};
|
||||
for (const declaration of declarations.values()) {
|
||||
if (!declaration.required || skipPermissions.has(declaration.permission)) continue;
|
||||
await this.authorize(context, {
|
||||
permission: declaration.permission,
|
||||
resources: declaration.resources.length ? declaration.resources : ["*"],
|
||||
...(declaration.resources.length
|
||||
&& (declaration.permission === "filesystem.read"
|
||||
|| declaration.permission === "filesystem.write")
|
||||
? { resourceKinds: declaration.resources.map(() => "directory") }
|
||||
: {}),
|
||||
reason: declaration.reason || `Required by ${plugin.id}`,
|
||||
operationId: `activation:${plugin.activeVersion}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
if (context.metadata.public === true) return next();
|
||||
if (!context.authorization) {
|
||||
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
|
||||
}
|
||||
await this.authorize(context, context.authorization);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
listGrants(pluginId) {
|
||||
return Object.freeze({
|
||||
always: Object.freeze(this.database.listPermissionGrants(pluginId).map((grant) => (
|
||||
Object.freeze({ ...grant, scope: "always", sessionId: null })
|
||||
))),
|
||||
application: Object.freeze([...this.applicationGrants.values()].filter((grant) => (
|
||||
grant.pluginId === pluginId
|
||||
))),
|
||||
session: Object.freeze([...this.sessionGrants.values()].filter((grant) => (
|
||||
grant.pluginId === pluginId
|
||||
))),
|
||||
});
|
||||
}
|
||||
|
||||
revokeAlways(pluginId, permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
const canonicalResource = canonicalizePermissionResource(permission, resource);
|
||||
this.database.deletePermissionGrant(
|
||||
pluginId,
|
||||
permission,
|
||||
canonicalResource,
|
||||
);
|
||||
this.database.recordSecurityAudit(pluginId, "permission.revoked", {
|
||||
scope: "always",
|
||||
permission,
|
||||
resource: canonicalResource,
|
||||
});
|
||||
this.#abortPrompts(pluginId, permission, canonicalResource);
|
||||
this.#emitRevocation({ pluginId, permission, resource: canonicalResource, scope: "always" });
|
||||
}
|
||||
|
||||
revokeApplication(pluginId, permission, resource) {
|
||||
if (permission !== undefined) assertPluginPermission(permission);
|
||||
if (resource !== undefined && permission === undefined) {
|
||||
throw new TypeError("Revoking a permission resource requires a permission name");
|
||||
}
|
||||
const canonicalResource = resource === undefined
|
||||
? undefined
|
||||
: canonicalizePermissionResource(permission, resource);
|
||||
for (const [key, grant] of [...this.applicationGrants]) {
|
||||
if (
|
||||
grant.pluginId === pluginId
|
||||
&& (permission === undefined || grant.permission === permission)
|
||||
&& (canonicalResource === undefined || grant.resource === canonicalResource)
|
||||
) this.applicationGrants.delete(key);
|
||||
}
|
||||
this.database.recordSecurityAudit(pluginId, "permission.revoked", {
|
||||
scope: "application",
|
||||
...(permission === undefined ? {} : { permission }),
|
||||
...(canonicalResource === undefined ? {} : { resource: canonicalResource }),
|
||||
});
|
||||
this.#abortPrompts(pluginId, permission, canonicalResource);
|
||||
this.#emitRevocation({
|
||||
pluginId,
|
||||
...(permission === undefined ? {} : { permission }),
|
||||
...(canonicalResource === undefined ? {} : { resource: canonicalResource }),
|
||||
scope: "application",
|
||||
});
|
||||
}
|
||||
|
||||
revokeSession(sessionId) {
|
||||
const controllers = this.sessionPromptControllers.get(sessionId);
|
||||
this.sessionPromptControllers.delete(sessionId);
|
||||
for (const controller of controllers ?? []) {
|
||||
controller.abort(permissionDenied("Plugin permission request session ended"));
|
||||
}
|
||||
for (const key of [...this.sessionGrants.keys()]) {
|
||||
if (key.startsWith(`${sessionId}\0`)) this.sessionGrants.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
revokeAll(pluginId) {
|
||||
this.database.deleteAllPermissionGrants(pluginId);
|
||||
for (const [key, grant] of [...this.applicationGrants]) {
|
||||
if (grant.pluginId === pluginId) this.applicationGrants.delete(key);
|
||||
}
|
||||
for (const [key, grant] of [...this.sessionGrants]) {
|
||||
if (grant.pluginId === pluginId) this.sessionGrants.delete(key);
|
||||
}
|
||||
this.database.recordSecurityAudit(pluginId, "permission.revoked", { scope: "all" });
|
||||
this.#abortPrompts(pluginId);
|
||||
this.#emitRevocation({ pluginId, scope: "all" });
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.shutdownController.abort(permissionDenied("Plugin permission engine is unavailable"));
|
||||
this.applicationGrants.clear();
|
||||
this.sessionGrants.clear();
|
||||
this.promptControllers.clear();
|
||||
this.revocationListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDITED_PERMISSION_USES,
|
||||
PROMPT_TIMEOUT_MS,
|
||||
PluginPermissionEngine,
|
||||
normalizePermissionOperationId,
|
||||
normalizePermissionReason,
|
||||
normalizePermissionResourceDescriptors,
|
||||
normalizePermissionResources,
|
||||
normalizePermissionSessionId,
|
||||
normalizeDecision,
|
||||
permissionDenied,
|
||||
};
|
||||
633
electron/plugins/permissionEngine.test.cjs
Normal file
633
electron/plugins/permissionEngine.test.cjs
Normal file
@@ -0,0 +1,633 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const {
|
||||
canonicalizeNetworkOrigin,
|
||||
defaultSecurityPrincipal,
|
||||
permissionResourceCovers,
|
||||
} = require("./permissionResources.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function createDatabase(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-permissions-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
}
|
||||
|
||||
function manifest(permissions) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.permissions",
|
||||
name: "permissions",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "index.js" },
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeContext(pluginManifest, overrides = {}) {
|
||||
return {
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("permission declarations are not grants and no renderer fails closed", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({ database });
|
||||
const pluginManifest = manifest({ required: ["storage"] });
|
||||
await assert.rejects(
|
||||
engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Use storage",
|
||||
}),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id).length, 0);
|
||||
assert.equal(database.listSecurityAudit(pluginManifest.id)[0].event, "permission.denied");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("always grants are declaration-bound and reusable until permission semantics change", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let prompts = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
prompts += 1;
|
||||
return { requestId: request.requestId, decision: "allow", scope: "always" };
|
||||
},
|
||||
});
|
||||
const optional = manifest({ optional: ["storage"] });
|
||||
const descriptor = { permission: "storage", resources: ["*"], reason: "Use storage" };
|
||||
await engine.authorize(runtimeContext(optional), descriptor);
|
||||
await engine.authorize(runtimeContext(optional), descriptor);
|
||||
assert.equal(prompts, 1);
|
||||
assert.equal(database.listPermissionGrants(optional.id).length, 1);
|
||||
|
||||
const required = manifest({ required: ["storage"] });
|
||||
await engine.authorize(runtimeContext(required), descriptor);
|
||||
assert.equal(prompts, 2, "optional-to-required changes require a fresh decision");
|
||||
const newPublisher = { ...required, publisher: "different-publisher" };
|
||||
await engine.authorize(runtimeContext(newPublisher), descriptor);
|
||||
assert.equal(prompts, 3, "a different security principal requires a fresh decision");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission decisions cannot persist resources broader than the manifest declaration", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-declared-root-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "file.txt");
|
||||
fs.writeFileSync(file, "hello");
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "always",
|
||||
resources: [path.dirname(root)],
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: [{
|
||||
permission: "filesystem.read",
|
||||
resources: [root],
|
||||
}] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [file],
|
||||
reason: "Read file",
|
||||
}), /exceeds the manifest declaration/);
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id).length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("an explicit empty allow-decision never widens to every requested resource", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "always",
|
||||
resources: [],
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Use storage",
|
||||
}), /does not cover the requested resources/);
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id).length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("application grants can be listed and revoked for the permission UI", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let prompts = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
prompts += 1;
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
const descriptor = { permission: "storage", resources: ["*"], reason: "Storage" };
|
||||
await engine.authorize(runtimeContext(pluginManifest), descriptor);
|
||||
assert.equal(engine.listGrants(pluginManifest.id).application[0].permission, "storage");
|
||||
engine.revokeApplication(pluginManifest.id, "storage", "*");
|
||||
await engine.authorize(runtimeContext(pluginManifest), descriptor);
|
||||
assert.equal(prompts, 2);
|
||||
engine.revokeAll(pluginManifest.id);
|
||||
assert.deepEqual(engine.listGrants(pluginManifest.id), {
|
||||
always: [],
|
||||
application: [],
|
||||
session: [],
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission decision provider failures are audited and fail closed", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async () => { throw new Error("renderer crashed"); },
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Storage",
|
||||
}), /decision provider failed/);
|
||||
assert.equal(database.listSecurityAudit(pluginManifest.id)[0].details.reason, "decision-provider-failed");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission decision providers cannot bypass the canonical decision shape", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
const descriptor = { permission: "storage", resources: ["*"], reason: "Storage" };
|
||||
for (const decision of [
|
||||
{ decision: "allow", scope: "always", resources: { 0: "*", length: 1 } },
|
||||
{ decision: "allow", scope: "always", resources: Array(129).fill("*") },
|
||||
{ decision: "allow", scope: "always", resources: ["x".repeat(9_000)] },
|
||||
{ decision: "deny", scope: "always" },
|
||||
]) {
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => ({ requestId: request.requestId, ...decision }),
|
||||
});
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), descriptor), /decision/);
|
||||
}
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id).length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission requests stay inside the canonical UI contract bounds", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let captured;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
captured = request;
|
||||
return { requestId: request.requestId, decision: "deny" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "r".repeat(2_048),
|
||||
operationId: `filesystem:${"p".repeat(2_048)}`,
|
||||
}), /denied/);
|
||||
assert.equal(captured.reason.length, 1_024);
|
||||
assert.match(captured.operationId, /^sha256:[a-f0-9]{64}$/u);
|
||||
assert.equal(Object.hasOwn(captured, "sessionId"), false);
|
||||
assert.equal(Object.isFrozen(captured), true);
|
||||
assert.deepEqual(captured.resourceKinds, ["exact"]);
|
||||
assert.equal(Object.isFrozen(captured.resourceKinds), true);
|
||||
const validateRequest = createDefinitionValidator("PermissionRequest");
|
||||
assert.equal(validateRequest(captured), true, JSON.stringify(validateRequest.errors));
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: Array(129).fill("*"),
|
||||
reason: "Too many resources",
|
||||
}), /resources are invalid/);
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
resourceKinds: [],
|
||||
reason: "Misaligned resource kinds",
|
||||
}), /resource kinds are invalid/);
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Invalid session",
|
||||
sessionId: "bad\0session",
|
||||
}), /session ID is invalid/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("coalesced prompts do not widen a once decision to concurrent callers", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let prompts = 0;
|
||||
const decisions = [];
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: (request) => {
|
||||
prompts += 1;
|
||||
return new Promise((resolve) => decisions.push(() => resolve({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "once",
|
||||
})));
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
const descriptor = {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Storage",
|
||||
operationId: "storage:concurrent",
|
||||
};
|
||||
const first = engine.authorize(runtimeContext(pluginManifest), descriptor);
|
||||
const second = engine.authorize(runtimeContext(pluginManifest), descriptor);
|
||||
assert.equal(prompts, 1);
|
||||
decisions.shift()();
|
||||
await first;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(prompts, 2);
|
||||
decisions.shift()();
|
||||
await second;
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission shutdown aborts pending prompts before grants can persist", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let resolveDecision;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: (request) => new Promise((resolve) => {
|
||||
resolveDecision = () => resolve({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "always",
|
||||
});
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["storage"] });
|
||||
const pending = engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "storage",
|
||||
resources: ["*"],
|
||||
reason: "Storage",
|
||||
});
|
||||
engine.shutdown();
|
||||
resolveDecision();
|
||||
await assert.rejects(pending, /unavailable/);
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id).length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
for (const scope of ["application", "session", "always"]) {
|
||||
test(`${scope} directory grants canonicalize origins and cover filesystem descendants`, async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-permission-root-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const child = path.join(root, "child", "file.txt");
|
||||
fs.mkdirSync(path.dirname(child), { recursive: true });
|
||||
fs.writeFileSync(child, "hello");
|
||||
let prompts = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
prompts += 1;
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope,
|
||||
};
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["network", "filesystem.read"] });
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "network",
|
||||
resources: ["HTTPS://EXAMPLE.COM:443"],
|
||||
reason: "Network",
|
||||
...(scope === "session" ? { sessionId: "terminal-session-1" } : {}),
|
||||
});
|
||||
if (scope === "always") {
|
||||
assert.equal(database.listPermissionGrants(pluginManifest.id)[0].resource, "https://example.com");
|
||||
}
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [root],
|
||||
resourceKinds: ["directory"],
|
||||
reason: "Read directory",
|
||||
...(scope === "session" ? { sessionId: "terminal-session-1" } : {}),
|
||||
});
|
||||
const promptsBeforeDescendant = prompts;
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [child],
|
||||
reason: "Read descendant again",
|
||||
...(scope === "session" ? { sessionId: "terminal-session-1" } : {}),
|
||||
});
|
||||
assert.equal(prompts, promptsBeforeDescendant);
|
||||
assert.equal(permissionResourceCovers("filesystem.read", root, child), false);
|
||||
assert.equal(permissionResourceCovers("filesystem.read", root, child, "directory"), true);
|
||||
assert.equal(permissionResourceCovers("filesystem.read", root, `${root}-outside`, "directory"), false);
|
||||
assert.equal(canonicalizeNetworkOrigin("https://example.com/"), "https://example.com");
|
||||
assert.throws(() => canonicalizeNetworkOrigin("https://example.com/path"), /without credentials or a path/);
|
||||
database.close();
|
||||
});
|
||||
}
|
||||
|
||||
test("an exact file grant cannot become a directory subtree grant after replacement", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-permission-file-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const selected = path.join(root, "selected");
|
||||
fs.writeFileSync(selected, "file");
|
||||
let prompts = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
prompts += 1;
|
||||
return { requestId: request.requestId, decision: "allow", scope: "always" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ optional: [{
|
||||
permission: "filesystem.read",
|
||||
resources: [root],
|
||||
}] });
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [selected],
|
||||
resourceKinds: ["exact"],
|
||||
reason: "Read file",
|
||||
});
|
||||
fs.rmSync(selected);
|
||||
fs.mkdirSync(selected);
|
||||
const child = path.join(selected, "child.txt");
|
||||
fs.writeFileSync(child, "child");
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [selected],
|
||||
resourceKinds: ["directory"],
|
||||
reason: "Read replacement directory",
|
||||
});
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [child],
|
||||
resourceKinds: ["exact"],
|
||||
reason: "Read replacement child",
|
||||
});
|
||||
assert.equal(prompts, 2);
|
||||
assert.deepEqual(database.listPermissionGrants(pluginManifest.id).map((grant) => (
|
||||
[grant.resource, grant.resourceKind]
|
||||
)), [[selected, "directory"]]);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("companion resources accept canonical contribution identifiers", () => {
|
||||
const { canonicalizeCompanionResource } = require("./permissionResources.cjs");
|
||||
assert.equal(
|
||||
canonicalizeCompanionResource("com.example2.plugin.helper.process"),
|
||||
"com.example2.plugin.helper.process",
|
||||
);
|
||||
assert.throws(() => canonicalizeCompanionResource("com.example"));
|
||||
const pluginManifest = manifest({ optional: [] });
|
||||
const firstPackage = defaultSecurityPrincipal(pluginManifest, "a".repeat(64));
|
||||
const secondPackage = defaultSecurityPrincipal(pluginManifest, "b".repeat(64));
|
||||
assert.notEqual(firstPackage, secondPackage, "unsigned package updates cannot inherit grants");
|
||||
assert.match(firstPackage, /^unsigned-package:/u);
|
||||
});
|
||||
|
||||
test("session grants require a host-owned session identifier", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "session",
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["terminal.metadata"] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "terminal.metadata",
|
||||
resources: ["*"] ,
|
||||
reason: "Read terminal metadata",
|
||||
}), /host-owned session/);
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "terminal.metadata",
|
||||
resources: ["*"],
|
||||
reason: "Read terminal metadata",
|
||||
sessionId: "terminal-session-1",
|
||||
});
|
||||
engine.revokeSession("terminal-session-1");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("revoking a session aborts its pending permission prompt before it can grant", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let promptStarted;
|
||||
const started = new Promise((resolve) => { promptStarted = resolve; });
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: (_request, { signal }) => new Promise((_resolve, reject) => {
|
||||
promptStarted();
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["terminal.metadata"] });
|
||||
const pending = engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "terminal.metadata",
|
||||
resources: ["*"],
|
||||
reason: "Read terminal metadata",
|
||||
sessionId: "terminal-session-pending",
|
||||
allowedScopes: ["session"],
|
||||
});
|
||||
await started;
|
||||
engine.revokeSession("terminal-session-pending");
|
||||
await assert.rejects(pending, /session ended/u);
|
||||
assert.equal(engine.sessionGrants.size, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("host state is revalidated before a permission decision can persist", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => ({
|
||||
requestId: request.requestId,
|
||||
decision: "allow",
|
||||
scope: "application",
|
||||
}),
|
||||
});
|
||||
const pluginManifest = manifest({ optional: ["terminal.metadata"] });
|
||||
await assert.rejects(engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "terminal.metadata",
|
||||
resources: ["*"],
|
||||
reason: "Read terminal metadata",
|
||||
validateBeforeGrant: () => { throw new Error("runtime changed"); },
|
||||
}), /runtime changed/u);
|
||||
assert.equal(engine.applicationGrants.size, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission revocation aborts matching prompts and publishes a revocation event", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let started;
|
||||
const promptStarted = new Promise((resolve) => { started = resolve; });
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: (_request, { signal }) => new Promise((_resolve, reject) => {
|
||||
started();
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
const events = [];
|
||||
engine.onDidRevoke((event) => events.push(event));
|
||||
const pluginManifest = manifest({ optional: ["terminal.metadata"] });
|
||||
const pending = engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "terminal.metadata",
|
||||
resources: ["*"],
|
||||
reason: "Read terminal metadata",
|
||||
});
|
||||
await promptStarted;
|
||||
engine.revokeApplication(pluginManifest.id, "terminal.metadata", "*");
|
||||
await assert.rejects(pending, /revoked/u);
|
||||
assert.deepEqual(events, [{
|
||||
pluginId: pluginManifest.id,
|
||||
permission: "terminal.metadata",
|
||||
resource: "*",
|
||||
scope: "application",
|
||||
}]);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("required preflight rejects missing or wildcard resource-scoped activation bounds", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const requested = [];
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
requested.push(request.permission);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ required: ["network"] });
|
||||
await assert.rejects(engine.authorizeRequired({
|
||||
id: pluginManifest.id,
|
||||
activeVersion: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
}), /must declare resources/u);
|
||||
const wildcardManifest = manifest({ required: [{ permission: "network", resources: ["*"] }] });
|
||||
await assert.rejects(engine.authorizeRequired({
|
||||
id: wildcardManifest.id,
|
||||
activeVersion: wildcardManifest.version,
|
||||
manifest: wildcardManifest,
|
||||
}), /must not use wildcard resources/u);
|
||||
assert.deepEqual(requested, []);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("required preflight prompts for non-resource permissions and bounded resources", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const requested = [];
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
requested.push({ permission: request.permission, resources: request.resources });
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = {
|
||||
...manifest({ required: [
|
||||
"runtime.advanced",
|
||||
{ permission: "network", resources: ["https://example.com"] },
|
||||
] }),
|
||||
main: { node: "index.js" },
|
||||
};
|
||||
await engine.authorizeRequired({
|
||||
id: pluginManifest.id,
|
||||
activeVersion: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
assert.deepEqual(requested, [
|
||||
{ permission: "runtime.advanced", resources: ["*"] },
|
||||
{ permission: "network", resources: ["https://example.com"] },
|
||||
]);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("required filesystem preflight preserves declared directory scope", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-required-directory-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const child = path.join(root, "child.txt");
|
||||
let prompts = 0;
|
||||
const requests = [];
|
||||
const engine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: async (request) => {
|
||||
prompts += 1;
|
||||
requests.push(request);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
const pluginManifest = manifest({ required: [{
|
||||
permission: "filesystem.read",
|
||||
resources: [root],
|
||||
}] });
|
||||
await engine.authorizeRequired({
|
||||
id: pluginManifest.id,
|
||||
activeVersion: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
assert.deepEqual(requests[0].resourceKinds, ["directory"]);
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [child],
|
||||
resourceKinds: ["exact"],
|
||||
reason: "Read declared descendant",
|
||||
});
|
||||
assert.equal(prompts, 1);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("permission middleware rejects unclassified methods and permits explicit public methods", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({ database });
|
||||
const middleware = engine.createMiddleware();
|
||||
await assert.rejects(middleware({
|
||||
pluginId: "com.example.permissions",
|
||||
method: "unclassified.method",
|
||||
metadata: {},
|
||||
}, async () => null), /no authorization policy/);
|
||||
assert.equal(await middleware({
|
||||
pluginId: "com.example.permissions",
|
||||
method: "log.write",
|
||||
metadata: { public: true },
|
||||
}, async () => "ok"), "ok");
|
||||
database.close();
|
||||
});
|
||||
194
electron/plugins/permissionResources.cjs
Normal file
194
electron/plugins/permissionResources.cjs
Normal file
@@ -0,0 +1,194 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const contractSchema = require("./generated/plugin-contract.schema.json");
|
||||
|
||||
const PLUGIN_PERMISSIONS = new Set(contractSchema.$defs.PluginPermission.enum);
|
||||
const RESOURCE_SCOPED_PERMISSIONS = new Set(
|
||||
contractSchema.$defs.ResourceScopedPermission.enum,
|
||||
);
|
||||
|
||||
function assertPluginPermission(permission) {
|
||||
if (!PLUGIN_PERMISSIONS.has(permission)) {
|
||||
throw new TypeError(`Unknown plugin permission: ${String(permission)}`);
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
function canonicalizeNetworkOrigin(value) {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 2_048) {
|
||||
throw new TypeError("Plugin network origin is invalid");
|
||||
}
|
||||
let parsed;
|
||||
try { parsed = new URL(value); }
|
||||
catch { throw new TypeError("Plugin network origin is invalid"); }
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new TypeError("Plugin network access supports only HTTP and HTTPS origins");
|
||||
}
|
||||
if (
|
||||
parsed.username
|
||||
|| parsed.password
|
||||
|| (parsed.pathname !== "" && parsed.pathname !== "/")
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
) throw new TypeError("Plugin network grants must contain an origin without credentials or a path");
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function canonicalizeCompanionResource(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 5
|
||||
|| value.length > 192
|
||||
|| !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\.[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)*$/u.test(value)
|
||||
) throw new TypeError("Plugin companion resource is invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalizeFilesystemResource(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 8_192
|
||||
|| value.includes("\0")
|
||||
|| !path.isAbsolute(value)
|
||||
) {
|
||||
throw new TypeError("Plugin filesystem grants require an absolute canonical path");
|
||||
}
|
||||
return path.normalize(value);
|
||||
}
|
||||
|
||||
function canonicalizePermissionResource(permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
if (resource === "*") return resource;
|
||||
if (permission === "network") return canonicalizeNetworkOrigin(resource);
|
||||
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||||
return canonicalizeFilesystemResource(resource);
|
||||
}
|
||||
if (typeof resource !== "string" || resource.length < 1 || resource.length > 2_048) {
|
||||
throw new TypeError("Plugin permission resource is invalid");
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
function normalizeForFilesystemComparison(value) {
|
||||
const normalized = path.resolve(value);
|
||||
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
|
||||
}
|
||||
|
||||
function permissionResourceCovers(
|
||||
permission,
|
||||
grantResource,
|
||||
requestedResource,
|
||||
resourceKind = "exact",
|
||||
requestedResourceKind = "exact",
|
||||
) {
|
||||
if (grantResource === "*") return true;
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||||
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
|
||||
const parent = normalizeForFilesystemComparison(grantResource);
|
||||
const candidate = normalizeForFilesystemComparison(requestedResource);
|
||||
if (resourceKind !== "directory") return parent === candidate;
|
||||
const relative = path.relative(parent, candidate);
|
||||
return relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
||||
}
|
||||
return grantResource === requestedResource;
|
||||
}
|
||||
|
||||
function normalizePermissionDeclarations(manifest) {
|
||||
const declarations = new Map();
|
||||
for (const [required, values] of [
|
||||
[true, manifest?.permissions?.required ?? []],
|
||||
[false, manifest?.permissions?.optional ?? []],
|
||||
]) {
|
||||
for (const value of values) {
|
||||
const permission = assertPluginPermission(typeof value === "string" ? value : value.permission);
|
||||
const resources = typeof value === "string"
|
||||
? []
|
||||
: [...new Set((value.resources ?? []).map((resource) => (
|
||||
canonicalizePermissionResource(permission, resource)
|
||||
)))].sort();
|
||||
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
|
||||
if (resources.length === 0) {
|
||||
throw new TypeError(`Required permission ${permission} must declare resources`);
|
||||
}
|
||||
if (resources.includes("*")) {
|
||||
throw new TypeError(`Required permission ${permission} must not use wildcard resources`);
|
||||
}
|
||||
}
|
||||
declarations.set(permission, Object.freeze({
|
||||
permission,
|
||||
required,
|
||||
resources: Object.freeze(resources),
|
||||
reason: typeof value === "string" ? "" : value.reason ?? "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function defaultSecurityPrincipal(manifest, archiveSha256) {
|
||||
if (!manifest || typeof manifest.id !== "string" || typeof manifest.publisher !== "string") {
|
||||
throw new TypeError("Plugin security principal requires manifest identity");
|
||||
}
|
||||
if (
|
||||
archiveSha256 !== undefined
|
||||
&& (typeof archiveSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(archiveSha256))
|
||||
) throw new TypeError("Plugin security principal package digest is invalid");
|
||||
const digest = createHash("sha256")
|
||||
.update(manifest.id)
|
||||
.update("\0")
|
||||
.update(manifest.publisher)
|
||||
.update("\0")
|
||||
.update(archiveSha256 ?? "unbound-development-package")
|
||||
.digest("hex");
|
||||
return `${archiveSha256 ? "unsigned-package" : "unsigned-development"}:${digest}`;
|
||||
}
|
||||
|
||||
function assertSecurityPrincipal(value) {
|
||||
if (typeof value !== "string" || value.length < 16 || value.length > 256 || value.includes("\0")) {
|
||||
throw new TypeError("Plugin security principal is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function permissionDeclarationHash(declaration, securityPrincipal = "legacy:unbound-principal") {
|
||||
return createHash("sha256").update(JSON.stringify({
|
||||
permission: declaration.permission,
|
||||
required: declaration.required,
|
||||
resources: [...declaration.resources].sort(),
|
||||
securityPrincipal: assertSecurityPrincipal(securityPrincipal),
|
||||
})).digest("hex");
|
||||
}
|
||||
|
||||
function declarationAllowsResource(declaration, requestedResource) {
|
||||
if (declaration.resources.length === 0) return true;
|
||||
return declaration.resources.some((resource) => permissionResourceCovers(
|
||||
declaration.permission,
|
||||
resource,
|
||||
requestedResource,
|
||||
declaration.permission === "filesystem.read" || declaration.permission === "filesystem.write"
|
||||
? "directory"
|
||||
: "exact",
|
||||
));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLUGIN_PERMISSIONS,
|
||||
RESOURCE_SCOPED_PERMISSIONS,
|
||||
assertPluginPermission,
|
||||
assertSecurityPrincipal,
|
||||
canonicalizeCompanionResource,
|
||||
canonicalizeFilesystemResource,
|
||||
canonicalizeNetworkOrigin,
|
||||
canonicalizePermissionResource,
|
||||
declarationAllowsResource,
|
||||
defaultSecurityPrincipal,
|
||||
normalizePermissionDeclarations,
|
||||
permissionDeclarationHash,
|
||||
permissionResourceCovers,
|
||||
};
|
||||
1573
electron/plugins/pluginBridge.cjs
Normal file
1573
electron/plugins/pluginBridge.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1601
electron/plugins/pluginBridge.test.cjs
Normal file
1601
electron/plugins/pluginBridge.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
96
electron/plugins/pluginLogger.cjs
Normal file
96
electron/plugins/pluginLogger.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
|
||||
const { assertPluginStorageSegment } = require("./paths.cjs");
|
||||
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie)/iu;
|
||||
|
||||
function redactLogValue(value, key = "", depth = 0) {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
if (depth >= 8) return "[TRUNCATED]";
|
||||
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
|
||||
if (typeof value === "string") return value.slice(0, 2_048);
|
||||
if (Array.isArray(value)) {
|
||||
const output = [];
|
||||
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
output.push(descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, "", depth + 1)
|
||||
: "[UNAVAILABLE]");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const output = {};
|
||||
for (const entryKey of Object.keys(value).slice(0, 64)) {
|
||||
if (SENSITIVE_KEY.test(entryKey)) {
|
||||
output[entryKey.slice(0, 128)] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
|
||||
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, entryKey, depth + 1)
|
||||
: "[UNAVAILABLE]";
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return "[UNSUPPORTED]";
|
||||
}
|
||||
|
||||
class PluginLogger {
|
||||
constructor(options) {
|
||||
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
|
||||
this.directory = path.join(options.logsDirectory, this.pluginId);
|
||||
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
|
||||
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
|
||||
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
|
||||
this.pendingBytes = 0;
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
|
||||
write(level, message, fields) {
|
||||
const record = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
|
||||
message: String(message ?? "").slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
|
||||
});
|
||||
const line = `${record}\n`;
|
||||
const bytes = Buffer.byteLength(line);
|
||||
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
|
||||
this.pendingBytes += bytes;
|
||||
this.queue = this.queue
|
||||
.then(() => this.#append(line))
|
||||
.catch(() => {})
|
||||
.finally(() => { this.pendingBytes -= bytes; });
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async #append(line) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const activePath = path.join(this.directory, "runtime.log");
|
||||
let size = 0;
|
||||
try { size = (await fs.stat(activePath)).size; } catch {}
|
||||
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
|
||||
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
async #rotate() {
|
||||
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
|
||||
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
|
||||
path.join(this.directory, `runtime.${index}.log`),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginLogger, redactLogValue };
|
||||
43
electron/plugins/pluginLogger.test.cjs
Normal file
43
electron/plugins/pluginLogger.test.cjs
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
|
||||
|
||||
test("plugin logs redact structured secrets and remain bounded on disk", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-logs-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const logger = new PluginLogger({
|
||||
pluginId: "com.example.logger",
|
||||
logsDirectory: root,
|
||||
maxBytes: 240,
|
||||
maxFiles: 2,
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await logger.write("info", `record-${index}`, {
|
||||
nested: { apiToken: "must-not-appear", visible: index },
|
||||
});
|
||||
}
|
||||
const files = fs.readdirSync(path.join(root, "com.example.logger"));
|
||||
assert.ok(files.length <= 2);
|
||||
const contents = files.map((file) => fs.readFileSync(
|
||||
path.join(root, "com.example.logger", file),
|
||||
"utf8",
|
||||
)).join("\n");
|
||||
assert.doesNotMatch(contents, /must-not-appear/);
|
||||
assert.match(contents, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test("log redaction does not invoke accessors", () => {
|
||||
const value = {};
|
||||
Object.defineProperty(value, "secret", {
|
||||
enumerable: true,
|
||||
get() { throw new Error("accessor invoked"); },
|
||||
});
|
||||
assert.deepEqual(redactLogValue(value), { secret: "[REDACTED]" });
|
||||
});
|
||||
176
electron/plugins/pluginManager.cjs
Normal file
176
electron/plugins/pluginManager.cjs
Normal file
@@ -0,0 +1,176 @@
|
||||
"use strict";
|
||||
|
||||
class PluginManager {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.packageStore = options.packageStore;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.contributionService = options.contributionService ?? {
|
||||
initialize: () => this.runtimeSupervisor.startEnabled(),
|
||||
onPluginDisabled() {},
|
||||
onPluginEnabled: (pluginId) => this.runtimeSupervisor.start(pluginId),
|
||||
};
|
||||
this.beforeClose = options.beforeClose ?? null;
|
||||
this.initialized = false;
|
||||
this.initializePromise = null;
|
||||
this.mutationTail = Promise.resolve();
|
||||
this.shuttingDown = false;
|
||||
this.shutdownPromise = null;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.initializePromise ??= this.#initialize();
|
||||
return this.initializePromise;
|
||||
}
|
||||
|
||||
async #initialize() {
|
||||
await this.packageStore.initialize();
|
||||
await this.contributionService.initialize();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async #ready() {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
#mutate(operation) {
|
||||
if (this.shuttingDown) return Promise.reject(new Error("Plugin manager is shutting down"));
|
||||
const result = this.mutationTail.then(operation, operation);
|
||||
this.mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async list() {
|
||||
await this.#ready();
|
||||
return this.database.listPlugins();
|
||||
}
|
||||
|
||||
install(archivePath, options) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
let stoppedPlugin = null;
|
||||
let plugin;
|
||||
try {
|
||||
plugin = await this.packageStore.install(archivePath, {
|
||||
enable: options?.enable === true,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
this.contributionService.onPluginDisabled(pluginId);
|
||||
stoppedPlugin = { pluginId, version: previousPlugin.activeVersion };
|
||||
await this.runtimeSupervisor.stop(pluginId);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (stoppedPlugin) {
|
||||
const current = this.database.getActivePlugin(stoppedPlugin.pluginId);
|
||||
if (current?.activeVersion === stoppedPlugin.version) {
|
||||
this.database.setEnabled(stoppedPlugin.pluginId, true);
|
||||
try {
|
||||
await this.contributionService.onPluginEnabled(stoppedPlugin.pluginId);
|
||||
} catch {
|
||||
this.database.setEnabled(stoppedPlugin.pluginId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (plugin?.enabled) {
|
||||
try {
|
||||
await this.contributionService.onPluginEnabled(plugin.id);
|
||||
} catch (error) {
|
||||
this.database.setEnabled(plugin.id, false);
|
||||
this.contributionService.onPluginDisabled(plugin.id);
|
||||
if (stoppedPlugin && stoppedPlugin.version !== plugin.activeVersion) {
|
||||
try {
|
||||
this.database.setActiveVersion(stoppedPlugin.pluginId, stoppedPlugin.version, {
|
||||
enabled: true,
|
||||
expectedActiveVersion: plugin.activeVersion,
|
||||
});
|
||||
await this.contributionService.onPluginEnabled(stoppedPlugin.pluginId);
|
||||
} catch {
|
||||
const restored = this.database.getActivePlugin(stoppedPlugin.pluginId);
|
||||
if (restored?.activeVersion === stoppedPlugin.version && restored.enabled) {
|
||||
this.database.setEnabled(stoppedPlugin.pluginId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return plugin;
|
||||
});
|
||||
}
|
||||
|
||||
setEnabled(pluginId, enabled) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
if (enabled) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (plugin?.runtime?.quarantinedAt != null) {
|
||||
this.database.clearQuarantine(pluginId, plugin.activeVersion);
|
||||
}
|
||||
this.database.setEnabled(pluginId, true);
|
||||
try { await this.contributionService.onPluginEnabled(pluginId); }
|
||||
catch (error) {
|
||||
this.database.setEnabled(pluginId, false);
|
||||
this.contributionService.onPluginDisabled(pluginId);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
this.database.setEnabled(pluginId, false);
|
||||
this.contributionService.onPluginDisabled(pluginId);
|
||||
await this.runtimeSupervisor.stop(pluginId);
|
||||
}
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
restart(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
await this.runtimeSupervisor.restart(pluginId);
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
uninstall(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (plugin) {
|
||||
this.database.setEnabled(pluginId, false);
|
||||
this.contributionService.onPluginDisabled(pluginId);
|
||||
}
|
||||
await this.runtimeSupervisor.stop(pluginId);
|
||||
return this.packageStore.uninstall(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.shutdownPromise ??= this.#shutdown();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
async #shutdown() {
|
||||
this.shuttingDown = true;
|
||||
let supervisorError;
|
||||
const supervisorShutdown = Promise.resolve()
|
||||
.then(() => this.runtimeSupervisor.shutdown())
|
||||
.catch((error) => { supervisorError = error; });
|
||||
await this.mutationTail;
|
||||
if (this.initializePromise) {
|
||||
try { await this.initializePromise; } catch {}
|
||||
}
|
||||
await supervisorShutdown;
|
||||
const errors = supervisorError ? [supervisorError] : [];
|
||||
try { await this.beforeClose?.(); }
|
||||
catch (error) { errors.push(error); }
|
||||
try { this.database.close(); }
|
||||
catch (error) { errors.push(error); }
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Plugin host shutdown failed");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginManager };
|
||||
490
electron/plugins/pluginManager.test.cjs
Normal file
490
electron/plugins/pluginManager.test.cjs
Normal file
@@ -0,0 +1,490 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
|
||||
test("failed activation leaves a newly installed plugin disabled", async () => {
|
||||
const plugin = { id: "com.example.activation-failure", enabled: true };
|
||||
let enabled = true;
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
listPlugins: () => [],
|
||||
setEnabled(pluginId, nextEnabled) {
|
||||
assert.equal(pluginId, plugin.id);
|
||||
enabled = nextEnabled;
|
||||
},
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install() { return plugin; },
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async start() { throw new Error("activation failed"); },
|
||||
async startEnabled() {},
|
||||
async stop() {},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.install("/tmp/plugin.ncpkg", { enable: true }), /activation failed/);
|
||||
assert.equal(enabled, false);
|
||||
});
|
||||
|
||||
test("management mutations are serialized in invocation order", async () => {
|
||||
const calls = [];
|
||||
let releaseInstall;
|
||||
const installBlocked = new Promise((resolve) => { releaseInstall = resolve; });
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: (pluginId) => ({ id: pluginId, enabled: false }),
|
||||
listPlugins: () => [],
|
||||
setEnabled(pluginId, enabled) { calls.push(`enabled:${pluginId}:${enabled}`); },
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install() {
|
||||
calls.push("install:start");
|
||||
await installBlocked;
|
||||
calls.push("install:end");
|
||||
return { id: "com.example.serial", enabled: false };
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop(pluginId) { calls.push(`stop:${pluginId}`); },
|
||||
},
|
||||
});
|
||||
|
||||
const install = manager.install("/tmp/plugin.ncpkg");
|
||||
const disable = manager.setEnabled("com.example.serial", false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["install:start"]);
|
||||
releaseInstall();
|
||||
await Promise.all([install, disable]);
|
||||
assert.deepEqual(calls, [
|
||||
"install:start",
|
||||
"install:end",
|
||||
"enabled:com.example.serial:false",
|
||||
"stop:com.example.serial",
|
||||
]);
|
||||
});
|
||||
|
||||
test("explicit enable clears the active version quarantine before starting", async () => {
|
||||
const calls = [];
|
||||
const pluginId = "com.example.recover";
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({
|
||||
id: pluginId,
|
||||
activeVersion: "1.0.0",
|
||||
enabled: false,
|
||||
runtime: { quarantinedAt: 123 },
|
||||
}),
|
||||
listPlugins: () => [],
|
||||
clearQuarantine(id, version) { calls.push(`recover:${id}@${version}`); },
|
||||
setEnabled(id, enabled) { calls.push(`enabled:${id}:${enabled}`); },
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async start(id) { calls.push(`start:${id}`); },
|
||||
},
|
||||
});
|
||||
|
||||
await manager.setEnabled(pluginId, true);
|
||||
assert.deepEqual(calls, [
|
||||
`recover:${pluginId}@1.0.0`,
|
||||
`enabled:${pluginId}:true`,
|
||||
`start:${pluginId}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("installing an enabled version replaces the active runtime", async () => {
|
||||
const calls = [];
|
||||
const plugin = { id: "com.example.upgrade", enabled: true };
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
listPlugins: () => [],
|
||||
setEnabled(pluginId, enabled) { calls.push(`enabled:${pluginId}:${enabled}`); },
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install(_archivePath, options) {
|
||||
await options.beforeActivate({
|
||||
pluginId: plugin.id,
|
||||
version: "2.0.0",
|
||||
previousPlugin: {
|
||||
id: plugin.id,
|
||||
enabled: true,
|
||||
activeVersion: "1.0.0",
|
||||
},
|
||||
reason: "switch-active-version",
|
||||
});
|
||||
calls.push("activate:2.0.0");
|
||||
return plugin;
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop(pluginId) { calls.push(`stop:${pluginId}`); },
|
||||
async start(pluginId) { calls.push(`start:${pluginId}`); },
|
||||
},
|
||||
});
|
||||
assert.equal(await manager.install("/tmp/upgrade.ncpkg"), plugin);
|
||||
assert.deepEqual(calls, [
|
||||
"enabled:com.example.upgrade:false",
|
||||
"stop:com.example.upgrade",
|
||||
"activate:2.0.0",
|
||||
"start:com.example.upgrade",
|
||||
]);
|
||||
});
|
||||
|
||||
test("failed version preparation restores the previously enabled runtime", async () => {
|
||||
const calls = [];
|
||||
const pluginId = "com.example.upgrade-rollback";
|
||||
let activePlugin = {
|
||||
id: pluginId,
|
||||
enabled: true,
|
||||
activeVersion: "1.0.0",
|
||||
};
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ ...activePlugin }),
|
||||
listPlugins: () => [],
|
||||
setEnabled(id, enabled) {
|
||||
assert.equal(id, pluginId);
|
||||
activePlugin = { ...activePlugin, enabled };
|
||||
calls.push(`enabled:${enabled}`);
|
||||
},
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install(_archivePath, options) {
|
||||
await options.beforeActivate({
|
||||
pluginId,
|
||||
version: "2.0.0",
|
||||
previousPlugin: { ...activePlugin },
|
||||
reason: "switch-active-version",
|
||||
});
|
||||
calls.push("prepare:failed");
|
||||
throw new Error("database switch failed");
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop(id) { calls.push(`stop:${id}`); },
|
||||
async start(id) { calls.push(`start:${id}`); },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.install("/tmp/upgrade.ncpkg"), /database switch failed/);
|
||||
assert.deepEqual(calls, [
|
||||
"enabled:false",
|
||||
`stop:${pluginId}`,
|
||||
"prepare:failed",
|
||||
"enabled:true",
|
||||
`start:${pluginId}`,
|
||||
]);
|
||||
assert.equal(activePlugin.enabled, true);
|
||||
});
|
||||
|
||||
test("failed upgraded-version activation restores and restarts the previous version", async () => {
|
||||
const calls = [];
|
||||
const pluginId = "com.example.upgrade-activation-rollback";
|
||||
let activePlugin = {
|
||||
id: pluginId,
|
||||
enabled: true,
|
||||
activeVersion: "1.0.0",
|
||||
};
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ ...activePlugin }),
|
||||
listPlugins: () => [],
|
||||
setActiveVersion(id, version, options) {
|
||||
assert.equal(id, pluginId);
|
||||
assert.deepEqual(options, { enabled: true, expectedActiveVersion: "2.0.0" });
|
||||
activePlugin = { ...activePlugin, activeVersion: version, enabled: options.enabled };
|
||||
calls.push(`active:${version}:${options.enabled}`);
|
||||
},
|
||||
setEnabled(id, enabled) {
|
||||
assert.equal(id, pluginId);
|
||||
activePlugin = { ...activePlugin, enabled };
|
||||
calls.push(`enabled:${enabled}`);
|
||||
},
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install(_archivePath, options) {
|
||||
await options.beforeActivate({
|
||||
pluginId,
|
||||
version: "2.0.0",
|
||||
previousPlugin: { ...activePlugin },
|
||||
reason: "switch-active-version",
|
||||
});
|
||||
activePlugin = { ...activePlugin, activeVersion: "2.0.0", enabled: true };
|
||||
calls.push("active:2.0.0:true");
|
||||
return { ...activePlugin };
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop(id) { calls.push(`stop:${id}`); },
|
||||
async start(id) {
|
||||
calls.push(`start:${id}@${activePlugin.activeVersion}`);
|
||||
if (activePlugin.activeVersion === "2.0.0") throw new Error("new activation failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.install("/tmp/upgrade.ncpkg"), /new activation failed/);
|
||||
assert.deepEqual(calls, [
|
||||
"enabled:false",
|
||||
`stop:${pluginId}`,
|
||||
"active:2.0.0:true",
|
||||
`start:${pluginId}@2.0.0`,
|
||||
"enabled:false",
|
||||
"active:1.0.0:true",
|
||||
`start:${pluginId}@1.0.0`,
|
||||
]);
|
||||
assert.deepEqual(activePlugin, { id: pluginId, enabled: true, activeVersion: "1.0.0" });
|
||||
});
|
||||
|
||||
test("failed previous-version restart leaves the restored version disabled", async () => {
|
||||
const pluginId = "com.example.upgrade-rollback-fails";
|
||||
let activePlugin = { id: pluginId, enabled: true, activeVersion: "1.0.0" };
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ ...activePlugin }),
|
||||
listPlugins: () => [],
|
||||
setActiveVersion(_id, version, options) {
|
||||
activePlugin = { ...activePlugin, activeVersion: version, enabled: options.enabled };
|
||||
},
|
||||
setEnabled(_id, enabled) { activePlugin = { ...activePlugin, enabled }; },
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install(_archivePath, options) {
|
||||
await options.beforeActivate({
|
||||
pluginId,
|
||||
version: "2.0.0",
|
||||
previousPlugin: { ...activePlugin },
|
||||
reason: "switch-active-version",
|
||||
});
|
||||
activePlugin = { ...activePlugin, activeVersion: "2.0.0", enabled: true };
|
||||
return { ...activePlugin };
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop() {},
|
||||
async start() { throw new Error("runtime unavailable"); },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.install("/tmp/upgrade.ncpkg"), /runtime unavailable/);
|
||||
assert.deepEqual(activePlugin, { id: pluginId, enabled: false, activeVersion: "1.0.0" });
|
||||
});
|
||||
|
||||
test("failed restoration leaves the previous plugin disabled", async () => {
|
||||
const calls = [];
|
||||
const pluginId = "com.example.upgrade-broken-rollback";
|
||||
let activePlugin = { id: pluginId, enabled: true, activeVersion: "1.0.0" };
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ ...activePlugin }),
|
||||
listPlugins: () => [],
|
||||
setEnabled(id, enabled) {
|
||||
assert.equal(id, pluginId);
|
||||
activePlugin = { ...activePlugin, enabled };
|
||||
calls.push(`enabled:${enabled}`);
|
||||
},
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async install(_archivePath, options) {
|
||||
await options.beforeActivate({
|
||||
pluginId,
|
||||
version: "2.0.0",
|
||||
previousPlugin: { ...activePlugin },
|
||||
reason: "replace-active-files",
|
||||
});
|
||||
throw new Error("replacement failed");
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop() { calls.push("stop"); },
|
||||
async start() {
|
||||
calls.push("start:failed");
|
||||
throw new Error("previous package is invalid");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.install("/tmp/upgrade.ncpkg"), /replacement failed/);
|
||||
assert.deepEqual(calls, [
|
||||
"enabled:false",
|
||||
"stop",
|
||||
"enabled:true",
|
||||
"start:failed",
|
||||
"enabled:false",
|
||||
]);
|
||||
assert.equal(activePlugin.enabled, false);
|
||||
});
|
||||
|
||||
test("uninstall disables lazy activation before stopping and removing code", async () => {
|
||||
const calls = [];
|
||||
const pluginId = "com.example.remove";
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ id: pluginId, enabled: true }),
|
||||
listPlugins: () => [],
|
||||
setEnabled(id, enabled) { calls.push(`enabled:${id}:${enabled}`); },
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async uninstall(id) {
|
||||
calls.push(`uninstall:${id}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop(id) { calls.push(`stop:${id}`); },
|
||||
},
|
||||
});
|
||||
assert.equal(await manager.uninstall(pluginId), true);
|
||||
assert.deepEqual(calls, [
|
||||
`enabled:${pluginId}:false`,
|
||||
`stop:${pluginId}`,
|
||||
`uninstall:${pluginId}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("uninstall never removes package code when runtime cleanup fails", async () => {
|
||||
let uninstallCalls = 0;
|
||||
const pluginId = "com.example.contained";
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() {},
|
||||
getActivePlugin: () => ({ id: pluginId, enabled: true }),
|
||||
listPlugins: () => [],
|
||||
setEnabled() {},
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async uninstall() {
|
||||
uninstallCalls += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async stop() { throw new Error("companion cleanup failed"); },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(manager.uninstall(pluginId), /companion cleanup failed/);
|
||||
assert.equal(uninstallCalls, 0);
|
||||
});
|
||||
|
||||
test("concurrent shutdown callers share one complete shutdown operation", async () => {
|
||||
let databaseCloses = 0;
|
||||
let supervisorShutdowns = 0;
|
||||
let beforeCloses = 0;
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() { databaseCloses += 1; },
|
||||
listPlugins: () => [],
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
async shutdown() { supervisorShutdowns += 1; },
|
||||
},
|
||||
async beforeClose() { beforeCloses += 1; },
|
||||
});
|
||||
await manager.initialize();
|
||||
|
||||
const first = manager.shutdown();
|
||||
const second = manager.shutdown();
|
||||
assert.equal(first, second);
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(supervisorShutdowns, 1);
|
||||
assert.equal(beforeCloses, 1);
|
||||
assert.equal(databaseCloses, 1);
|
||||
});
|
||||
|
||||
test("shutdown closes the database even when secure-service cleanup fails", async () => {
|
||||
let databaseCloses = 0;
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() { databaseCloses += 1; },
|
||||
listPlugins: () => [],
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
runtimeSupervisor: { async startEnabled() {}, async shutdown() {} },
|
||||
async beforeClose() { throw new Error("secure cleanup failed"); },
|
||||
});
|
||||
await manager.initialize();
|
||||
await assert.rejects(manager.shutdown(), /secure cleanup failed/);
|
||||
assert.equal(databaseCloses, 1);
|
||||
});
|
||||
|
||||
test("shutdown cancels an in-flight runtime mutation before waiting for its queue", async () => {
|
||||
const calls = [];
|
||||
let rejectStart;
|
||||
let markStartEntered;
|
||||
const startEntered = new Promise((resolve) => { markStartEntered = resolve; });
|
||||
const pluginId = "com.example.shutdown-placement";
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
close() { calls.push("database:close"); },
|
||||
getActivePlugin: () => ({
|
||||
id: pluginId,
|
||||
activeVersion: "1.0.0",
|
||||
enabled: false,
|
||||
runtime: { quarantinedAt: null },
|
||||
}),
|
||||
listPlugins: () => [],
|
||||
setEnabled(_id, enabled) { calls.push(`enabled:${enabled}`); },
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
runtimeSupervisor: {
|
||||
async startEnabled() {},
|
||||
start() {
|
||||
calls.push("runtime:start");
|
||||
markStartEntered();
|
||||
return new Promise((_resolve, reject) => { rejectStart = reject; });
|
||||
},
|
||||
async shutdown() {
|
||||
calls.push("runtime:shutdown");
|
||||
rejectStart(new Error("placement cancelled by shutdown"));
|
||||
},
|
||||
},
|
||||
});
|
||||
await manager.initialize();
|
||||
|
||||
const enabling = manager.setEnabled(pluginId, true);
|
||||
await startEntered;
|
||||
const shuttingDown = manager.shutdown();
|
||||
await assert.rejects(enabling, /placement cancelled by shutdown/);
|
||||
await shuttingDown;
|
||||
assert.deepEqual(calls, [
|
||||
"enabled:true",
|
||||
"runtime:start",
|
||||
"runtime:shutdown",
|
||||
"enabled:false",
|
||||
"database:close",
|
||||
]);
|
||||
});
|
||||
201
electron/plugins/pluginProtocol.cjs
Normal file
201
electron/plugins/pluginProtocol.cjs
Normal file
@@ -0,0 +1,201 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
|
||||
const { normalizePluginModuleResources } = require("./moduleResources.cjs");
|
||||
const { isPathInside } = require("./paths.cjs");
|
||||
|
||||
const CONTENT_TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".gif", "image/gif"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".ico", "image/x-icon"],
|
||||
[".jpeg", "image/jpeg"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".mjs", "text/javascript; charset=utf-8"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
[".webp", "image/webp"],
|
||||
]);
|
||||
|
||||
function createRuntimeToken() {
|
||||
return randomBytes(24).toString("base64url").toLowerCase();
|
||||
}
|
||||
|
||||
function decodeRequestPath(pathname) {
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(pathname); } catch { throw new Error("Malformed plugin URL encoding"); }
|
||||
if (!decoded.startsWith("/") || decoded.includes("\0") || decoded.includes("\\")) {
|
||||
throw new Error("Unsafe plugin URL path");
|
||||
}
|
||||
const segments = decoded.slice(1).split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new Error("Unsafe plugin URL path");
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function readContainedFile(root, segments) {
|
||||
const candidate = path.resolve(root, ...segments);
|
||||
if (!isPathInside(root, candidate)) throw new Error("Plugin resource escapes its root");
|
||||
const [realRoot, realCandidate] = await Promise.all([fs.realpath(root), fs.realpath(candidate)]);
|
||||
if (!isPathInside(realRoot, realCandidate)) throw new Error("Plugin resource follows a symlink outside its root");
|
||||
const stats = await fs.lstat(realCandidate);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Plugin resource is not a regular file");
|
||||
return { body: await fs.readFile(realCandidate), filePath: realCandidate };
|
||||
}
|
||||
|
||||
function htmlForRuntime(token, moduleResources) {
|
||||
const imports = Object.fromEntries(moduleResources.map((resource) => [
|
||||
resource.specifier,
|
||||
`${PLUGIN_PROTOCOL_SCHEME}://${token}/__host/modules/${resource.route}/${resource.entry}`,
|
||||
]));
|
||||
const runtime = `${PLUGIN_PROTOCOL_SCHEME}://${token}/__host/runtime/browserRuntime.mjs`;
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Netcatty plugin runtime</title>
|
||||
<script type="importmap" nonce="${token}">${JSON.stringify({ imports })}</script></head><body><script type="module" src="${runtime}"></script></body></html>`;
|
||||
}
|
||||
|
||||
class PluginProtocol {
|
||||
constructor(options) {
|
||||
this.runtimeDirectory = options.runtimeDirectory;
|
||||
this.sdkDirectory = options.sdkDirectory;
|
||||
this.contractDirectory = options.contractDirectory;
|
||||
this.moduleResources = options.moduleResources
|
||||
? normalizePluginModuleResources(options.moduleResources)
|
||||
: normalizePluginModuleResources([
|
||||
{ specifier: "@netcatty/plugin-sdk", directory: options.sdkDirectory },
|
||||
{ specifier: "@netcatty/plugin-contract", directory: options.contractDirectory },
|
||||
]);
|
||||
this.moduleResourcesByRoute = new Map(this.moduleResources.map((resource) => [resource.route, resource]));
|
||||
this.registrations = new Map();
|
||||
this.sessions = new WeakSet();
|
||||
}
|
||||
|
||||
registerSession(session) {
|
||||
if (this.sessions.has(session)) {
|
||||
throw new Error("Plugin protocol is already registered for this session");
|
||||
}
|
||||
session.protocol.handle(PLUGIN_PROTOCOL_SCHEME, (request) => this.#handle(request));
|
||||
this.sessions.add(session);
|
||||
let disposed = false;
|
||||
return {
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
this.sessions.delete(session);
|
||||
session.protocol.unhandle(PLUGIN_PROTOCOL_SCHEME);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerRuntime(options) {
|
||||
const token = createRuntimeToken();
|
||||
this.registrations.set(token, {
|
||||
token,
|
||||
pluginId: options.pluginId,
|
||||
packageRoot: options.packageRoot,
|
||||
config: options.config,
|
||||
kind: "runtime",
|
||||
});
|
||||
return {
|
||||
token,
|
||||
url: `${PLUGIN_PROTOCOL_SCHEME}://${token}/index.html`,
|
||||
dispose: () => this.registrations.delete(token),
|
||||
};
|
||||
}
|
||||
|
||||
registerView(options) {
|
||||
const token = createRuntimeToken();
|
||||
const entry = options.entry.split("/").map(encodeURIComponent).join("/");
|
||||
this.registrations.set(token, {
|
||||
token,
|
||||
pluginId: options.pluginId,
|
||||
packageRoot: options.packageRoot,
|
||||
config: null,
|
||||
kind: "view",
|
||||
});
|
||||
return {
|
||||
token,
|
||||
url: `${PLUGIN_PROTOCOL_SCHEME}://${token}/package/${entry}`,
|
||||
dispose: () => this.registrations.delete(token),
|
||||
};
|
||||
}
|
||||
|
||||
async #handle(request) {
|
||||
const headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
const respond = (body, status, contentType = "text/plain; charset=utf-8", extraHeaders = {}) => (
|
||||
new Response(body, { status, headers: { ...headers, "Content-Type": contentType, ...extraHeaders } })
|
||||
);
|
||||
try {
|
||||
if (request.method !== "GET") return respond("Method Not Allowed", 405);
|
||||
const url = new URL(request.url);
|
||||
if (url.username || url.password || url.port || url.search || url.hash) return respond("Bad Request", 400);
|
||||
const registration = this.registrations.get(url.hostname);
|
||||
if (!registration) return respond("Not Found", 404);
|
||||
const segments = decodeRequestPath(url.pathname);
|
||||
if (registration.kind === "view" && segments[0] !== "package") {
|
||||
return respond("Not Found", 404);
|
||||
}
|
||||
const csp = registration.kind === "view"
|
||||
? `default-src 'none'; script-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; connect-src 'none'; img-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: data:; style-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: 'unsafe-inline'; font-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'`
|
||||
: `default-src 'none'; script-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: 'nonce-${registration.token}'; connect-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; img-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; style-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'`;
|
||||
if (segments.length === 1 && segments[0] === "index.html") {
|
||||
return respond(htmlForRuntime(registration.token, this.moduleResources), 200, "text/html; charset=utf-8", {
|
||||
"Content-Security-Policy": csp,
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), display-capture=(), usb=(), serial=(), hid=(), payment=(), fullscreen=(), clipboard-read=(), clipboard-write=()",
|
||||
});
|
||||
}
|
||||
if (segments.join("/") === "__host/runtime/config.json") {
|
||||
return respond(JSON.stringify(registration.config), 200, "application/json; charset=utf-8");
|
||||
}
|
||||
let root;
|
||||
let fileSegments;
|
||||
let packageResource = false;
|
||||
if (segments[0] === "package") {
|
||||
packageResource = true;
|
||||
root = registration.packageRoot;
|
||||
fileSegments = segments.slice(1);
|
||||
} else if (segments[0] === "__host" && segments[1] === "runtime") {
|
||||
root = this.runtimeDirectory;
|
||||
fileSegments = segments.slice(2);
|
||||
} else if (segments[0] === "__host" && segments[1] === "modules") {
|
||||
const resource = this.moduleResourcesByRoute.get(segments[2]);
|
||||
if (!resource) return respond("Not Found", 404);
|
||||
root = resource.directory;
|
||||
fileSegments = segments.slice(3);
|
||||
} else return respond("Not Found", 404);
|
||||
if (!fileSegments.length || (!packageResource && fileSegments.some((segment) => segment.startsWith(".")))) {
|
||||
return respond("Not Found", 404);
|
||||
}
|
||||
const file = await readContainedFile(root, fileSegments);
|
||||
const contentType = CONTENT_TYPES.get(path.extname(file.filePath).toLowerCase())
|
||||
?? "application/octet-stream";
|
||||
const viewHeaders = registration.kind === "view" ? {
|
||||
"Content-Security-Policy": csp,
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), display-capture=(), usb=(), serial=(), hid=(), payment=(), fullscreen=(), clipboard-read=(), clipboard-write=()",
|
||||
} : {};
|
||||
return respond(file.body, 200, contentType, viewHeaders);
|
||||
} catch {
|
||||
return respond("Not Found", 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PluginProtocol,
|
||||
createRuntimeToken,
|
||||
decodeRequestPath,
|
||||
readContainedFile,
|
||||
};
|
||||
187
electron/plugins/pluginProtocol.test.cjs
Normal file
187
electron/plugins/pluginProtocol.test.cjs
Normal file
@@ -0,0 +1,187 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PluginProtocol,
|
||||
decodeRequestPath,
|
||||
readContainedFile,
|
||||
} = require("./pluginProtocol.cjs");
|
||||
|
||||
function createRoots(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-protocol-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const roots = {
|
||||
packageRoot: path.join(root, "package"),
|
||||
runtimeDirectory: path.join(root, "runtime"),
|
||||
sdkDirectory: path.join(root, "sdk"),
|
||||
contractDirectory: path.join(root, "contract"),
|
||||
};
|
||||
for (const directory of Object.values(roots)) fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(roots.packageRoot, "index.js"), "export default {};\n");
|
||||
fs.mkdirSync(path.join(roots.packageRoot, ".runtime"));
|
||||
fs.writeFileSync(path.join(roots.packageRoot, ".runtime", "index.js"), "export default {};\n");
|
||||
fs.writeFileSync(path.join(roots.runtimeDirectory, "browserRuntime.mjs"), "export {};\n");
|
||||
fs.writeFileSync(path.join(roots.sdkDirectory, "index.js"), "export {};\n");
|
||||
fs.writeFileSync(path.join(roots.contractDirectory, "index.js"), "export {};\n");
|
||||
return roots;
|
||||
}
|
||||
|
||||
test("plugin URL decoding rejects traversal and platform separators", () => {
|
||||
assert.deepEqual(decodeRequestPath("/package/dist/index.js"), ["package", "dist", "index.js"]);
|
||||
for (const unsafe of ["/package/../escape", "/package/%2e%2e/escape", "/package/a%5cb", "/package//x"]) {
|
||||
assert.throws(() => decodeRequestPath(unsafe));
|
||||
}
|
||||
});
|
||||
|
||||
test("contained resource reads reject symlink escape", async (context) => {
|
||||
if (process.platform === "win32") return;
|
||||
const roots = createRoots(context);
|
||||
const outside = path.join(path.dirname(roots.packageRoot), "outside.js");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
fs.symlinkSync(outside, path.join(roots.packageRoot, "linked.js"));
|
||||
await assert.rejects(readContainedFile(roots.packageRoot, ["linked.js"]), /symlink outside/);
|
||||
});
|
||||
|
||||
test("runtime-scoped protocol serves only registered package and host resources", async (context) => {
|
||||
const roots = createRoots(context);
|
||||
let handler;
|
||||
let unhandled = false;
|
||||
const fakeSession = {
|
||||
protocol: {
|
||||
handle(scheme, callback) {
|
||||
assert.equal(scheme, "netcatty-plugin");
|
||||
handler = callback;
|
||||
},
|
||||
unhandle(scheme) {
|
||||
assert.equal(scheme, "netcatty-plugin");
|
||||
unhandled = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
const protocol = new PluginProtocol(roots);
|
||||
const sessionRegistration = protocol.registerSession(fakeSession);
|
||||
assert.throws(() => protocol.registerSession(fakeSession), /already registered/);
|
||||
const config = { pluginId: "com.example.test" };
|
||||
const registration = protocol.registerRuntime({
|
||||
pluginId: "com.example.test",
|
||||
packageRoot: roots.packageRoot,
|
||||
config,
|
||||
});
|
||||
|
||||
const page = await handler({ method: "GET", url: registration.url });
|
||||
assert.equal(page.status, 200);
|
||||
assert.doesNotMatch(page.headers.get("content-security-policy"), /unsafe-inline/);
|
||||
assert.match(page.headers.get("content-security-policy"), /nonce-/);
|
||||
assert.match(page.headers.get("content-security-policy"), /worker-src 'none'/);
|
||||
assert.equal(page.headers.get("x-dns-prefetch-control"), "off");
|
||||
assert.match(page.headers.get("permissions-policy"), /camera=\(\)/);
|
||||
const packageResponse = await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/package/index.js`,
|
||||
});
|
||||
assert.equal(await packageResponse.text(), "export default {};\n");
|
||||
const dotPackageResponse = await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/package/.runtime/index.js`,
|
||||
});
|
||||
assert.equal(dotPackageResponse.status, 200);
|
||||
assert.equal(await dotPackageResponse.text(), "export default {};\n");
|
||||
const configResponse = await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/__host/runtime/config.json`,
|
||||
});
|
||||
assert.deepEqual(await configResponse.json(), config);
|
||||
const otherRuntime = await handler({ method: "GET", url: "netcatty-plugin://unknown/package/index.js" });
|
||||
assert.equal(otherRuntime.status, 404);
|
||||
registration.dispose();
|
||||
assert.equal((await handler({ method: "GET", url: registration.url })).status, 404);
|
||||
sessionRegistration.dispose();
|
||||
sessionRegistration.dispose();
|
||||
assert.equal(unhandled, true);
|
||||
});
|
||||
|
||||
test("browser runtime import maps accept reviewed host modules without protocol changes", async (context) => {
|
||||
const roots = createRoots(context);
|
||||
const uiDirectory = path.join(path.dirname(roots.packageRoot), "ui");
|
||||
fs.mkdirSync(uiDirectory);
|
||||
fs.writeFileSync(path.join(uiDirectory, "index.js"), "export const ui = true;\n");
|
||||
let handler;
|
||||
const protocol = new PluginProtocol({
|
||||
runtimeDirectory: roots.runtimeDirectory,
|
||||
moduleResources: [
|
||||
{ specifier: "@netcatty/plugin-sdk", directory: roots.sdkDirectory },
|
||||
{ specifier: "@netcatty/plugin-contract", directory: roots.contractDirectory },
|
||||
{ specifier: "@netcatty/plugin-ui", directory: uiDirectory },
|
||||
],
|
||||
});
|
||||
protocol.registerSession({
|
||||
protocol: {
|
||||
handle(_scheme, callback) { handler = callback; },
|
||||
unhandle() {},
|
||||
},
|
||||
});
|
||||
const registration = protocol.registerRuntime({
|
||||
pluginId: "com.example.modules",
|
||||
packageRoot: roots.packageRoot,
|
||||
config: {},
|
||||
});
|
||||
const page = await (await handler({ method: "GET", url: registration.url })).text();
|
||||
assert.match(page, /@netcatty\/plugin-ui/);
|
||||
assert.match(page, /__host\/modules\/m2\/index\.js/);
|
||||
const moduleResponse = await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/__host/modules/m2/index.js`,
|
||||
});
|
||||
assert.equal(await moduleResponse.text(), "export const ui = true;\n");
|
||||
});
|
||||
|
||||
test("custom views load packaged resources only with a network-denying document policy", async (context) => {
|
||||
const roots = createRoots(context);
|
||||
fs.writeFileSync(path.join(roots.packageRoot, "view.html"), "<!doctype html><script src=\"view.js\"></script>\n");
|
||||
fs.writeFileSync(path.join(roots.packageRoot, "view.js"), "globalThis.ready = true;\n");
|
||||
fs.writeFileSync(path.join(roots.packageRoot, "view.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>\n");
|
||||
let handler;
|
||||
const protocol = new PluginProtocol(roots);
|
||||
protocol.registerSession({
|
||||
protocol: {
|
||||
handle(_scheme, callback) { handler = callback; },
|
||||
unhandle() {},
|
||||
},
|
||||
});
|
||||
const registration = protocol.registerView({
|
||||
pluginId: "com.example.view",
|
||||
packageRoot: roots.packageRoot,
|
||||
entry: "view.html",
|
||||
});
|
||||
const page = await handler({ method: "GET", url: registration.url });
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.headers.get("content-security-policy"), /connect-src 'none'/u);
|
||||
assert.match(page.headers.get("content-security-policy"), /frame-src 'none'/u);
|
||||
assert.match(page.headers.get("permissions-policy"), /clipboard-write=\(\)/u);
|
||||
const svgRegistration = protocol.registerView({
|
||||
pluginId: "com.example.svg-view",
|
||||
packageRoot: roots.packageRoot,
|
||||
entry: "view.svg",
|
||||
});
|
||||
const svgPage = await handler({ method: "GET", url: svgRegistration.url });
|
||||
assert.equal(svgPage.headers.get("content-type"), "image/svg+xml");
|
||||
assert.match(svgPage.headers.get("content-security-policy"), /worker-src 'none'/u);
|
||||
assert.match(svgPage.headers.get("permissions-policy"), /camera=\(\)/u);
|
||||
assert.equal((await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/package/view.js`,
|
||||
})).status, 200);
|
||||
assert.equal((await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/__host/runtime/browserRuntime.mjs`,
|
||||
})).status, 404);
|
||||
assert.equal((await handler({
|
||||
method: "GET",
|
||||
url: `netcatty-plugin://${registration.token}/index.html`,
|
||||
})).status, 404);
|
||||
});
|
||||
104
electron/plugins/pluginStreamEndpoint.test.cjs
Normal file
104
electron/plugins/pluginStreamEndpoint.test.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
async function createPair() {
|
||||
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
|
||||
let left;
|
||||
let right;
|
||||
const leftTransport = {
|
||||
post(message) { queueMicrotask(() => right.accept(message)); },
|
||||
};
|
||||
const rightTransport = {
|
||||
post(message) { queueMicrotask(() => left.accept(message)); },
|
||||
};
|
||||
left = createPluginStreamEndpoint(leftTransport);
|
||||
right = createPluginStreamEndpoint(rightTransport);
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test("runtime byte streams transfer owned chunks and end normally", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const readablePromise = right.acceptReadable("connection:output");
|
||||
const writable = await left.openWritable("connection:output", 1024);
|
||||
const readable = await readablePromise;
|
||||
|
||||
await writable.write(Uint8Array.from([1, 2, 3]));
|
||||
assert.deepEqual([...await readable.read()], [1, 2, 3]);
|
||||
const ended = writable.end();
|
||||
assert.equal(await readable.read(), null);
|
||||
await ended;
|
||||
});
|
||||
|
||||
test("runtime byte streams retain receive credit until the next read", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const readablePromise = right.acceptReadable("import:records");
|
||||
const writable = await left.openWritable("import:records", 1024);
|
||||
const readable = await readablePromise;
|
||||
const first = new Uint8Array(1024);
|
||||
await writable.write(first);
|
||||
assert.equal((await readable.read()).byteLength, 1024);
|
||||
|
||||
let secondSettled = false;
|
||||
const second = writable.write(Uint8Array.of(9)).then(() => { secondSettled = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(secondSettled, false);
|
||||
assert.deepEqual([...await readable.read()], [9]);
|
||||
await second;
|
||||
});
|
||||
|
||||
test("runtime byte stream cancellation rejects queued writes", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const readablePromise = right.acceptReadable("connection:input");
|
||||
const writable = await left.openWritable("connection:input", 1024);
|
||||
const readable = await readablePromise;
|
||||
await writable.write(new Uint8Array(1024));
|
||||
const blocked = writable.write(Uint8Array.of(1));
|
||||
readable.cancel();
|
||||
await assert.rejects(blocked, /cancel/i);
|
||||
});
|
||||
|
||||
test("runtime byte streams reject invalid identifiers and windows", async () => {
|
||||
const { left } = await createPair();
|
||||
await assert.rejects(left.openWritable("", 1024), /stream ID/i);
|
||||
await assert.rejects(left.openWritable("valid", 1), /window/i);
|
||||
});
|
||||
|
||||
test("runtime byte stream end waits behind backpressure and preserves ordering", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const readablePromise = right.acceptReadable("ordered-end");
|
||||
const writable = await left.openWritable("ordered-end", 1024);
|
||||
const readable = await readablePromise;
|
||||
await writable.write(new Uint8Array(1024));
|
||||
const blocked = writable.write(Uint8Array.of(7));
|
||||
const ending = writable.end();
|
||||
assert.equal((await readable.read()).byteLength, 1024);
|
||||
assert.deepEqual([...await readable.read()], [7]);
|
||||
await blocked;
|
||||
assert.equal(await readable.read(), null);
|
||||
await ending;
|
||||
});
|
||||
|
||||
test("runtime byte streams reject concurrent reads and bound pending accept reservations", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const readablePromise = right.acceptReadable("serial-read");
|
||||
await left.openWritable("serial-read", 1024);
|
||||
const readable = await readablePromise;
|
||||
const pendingRead = readable.read();
|
||||
await assert.rejects(readable.read(), /concurrent reads/i);
|
||||
readable.cancel();
|
||||
await assert.rejects(pendingRead, /cancel/i);
|
||||
|
||||
const firstRead = left.acceptReadable("read-once");
|
||||
await assert.rejects(left.acceptReadable("read-once"), /cannot be accepted/i);
|
||||
left.rejectReadable("read-once");
|
||||
await assert.rejects(firstRead, /cancel/i);
|
||||
|
||||
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
|
||||
const endpoint = createPluginStreamEndpoint({ post() {} }, { maxStreams: 1 });
|
||||
const reserved = endpoint.acceptReadable("reserved");
|
||||
await assert.rejects(endpoint.acceptReadable("overflow"), /limit/i);
|
||||
endpoint.rejectReadable("reserved");
|
||||
await assert.rejects(reserved, /cancel/i);
|
||||
});
|
||||
186
electron/plugins/pluginSyncSidecarHelpers.cjs
Normal file
186
electron/plugins/pluginSyncSidecarHelpers.cjs
Normal file
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Pure sidecar collect/merge helpers for the plugin host.
|
||||
* Mirrors domain/pluginSyncSidecar.ts so main-process CJS can load without TS.
|
||||
*/
|
||||
|
||||
const SIDECAR_KINDS = new Set(["settings", "account_baseline", "crdt_baseline"]);
|
||||
|
||||
function isPluginSyncSidecarKind(value) {
|
||||
return typeof value === "string" && SIDECAR_KINDS.has(value);
|
||||
}
|
||||
|
||||
function isCloudSyncablePluginSetting(field) {
|
||||
if (!field || field.secret === true) return false;
|
||||
return field.sync === true;
|
||||
}
|
||||
|
||||
function settingsSidecarKey(settingId, scope, scopeId) {
|
||||
return `${settingId}\0${scope}\0${scopeId}`;
|
||||
}
|
||||
|
||||
function parseSettingsSidecarKey(key) {
|
||||
const parts = String(key).split("\0");
|
||||
if (parts.length !== 3) return null;
|
||||
const [settingId, scope, scopeId] = parts;
|
||||
if (!settingId || !scope || !scopeId) return null;
|
||||
return { settingId, scope, scopeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a settings sidecar key onto the currently declared scope coordinates.
|
||||
* Returns null when the declared scope cannot be remapped safely (e.g. host
|
||||
* scope without a fresh host id).
|
||||
*/
|
||||
function resolveSettingsSidecarTarget(field, parsed) {
|
||||
const targetScope = typeof field?.scope === "string" && field.scope.length > 0
|
||||
? field.scope
|
||||
: parsed.scope;
|
||||
let targetScopeId = parsed.scopeId;
|
||||
if (targetScope === "application") {
|
||||
targetScopeId = "application";
|
||||
} else if (targetScope === "device") {
|
||||
// parseSettingsSidecarKey guarantees a non-empty scopeId when parsed is set.
|
||||
targetScopeId = parsed.scope === "device" ? parsed.scopeId : "device";
|
||||
} else if (targetScope !== parsed.scope) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
nextKey: settingsSidecarKey(parsed.settingId, targetScope, targetScopeId),
|
||||
};
|
||||
}
|
||||
|
||||
function collectPluginSyncSidecars(input) {
|
||||
const now = input.now ?? Date.now();
|
||||
const entries = [];
|
||||
const settingsIndex = new Map();
|
||||
|
||||
for (const existing of input.existingSidecars ?? []) {
|
||||
if (!existing || typeof existing.pluginId !== "string" || !isPluginSyncSidecarKind(existing.kind)) {
|
||||
continue;
|
||||
}
|
||||
if (existing.kind !== "settings") {
|
||||
entries.push({
|
||||
pluginId: existing.pluginId,
|
||||
kind: existing.kind,
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const copy = {
|
||||
pluginId: existing.pluginId,
|
||||
kind: "settings",
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
};
|
||||
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
|
||||
}
|
||||
|
||||
for (const stored of input.storedSettings ?? []) {
|
||||
if (!stored || typeof stored.pluginId !== "string" || typeof stored.settingId !== "string") continue;
|
||||
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
|
||||
if (!declared) continue;
|
||||
const field = declared.find((entry) => entry.id === stored.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
|
||||
const entry = {
|
||||
pluginId: stored.pluginId,
|
||||
kind: "settings",
|
||||
key,
|
||||
value: stored.value,
|
||||
updatedAt: Number(stored.updatedAt) || now,
|
||||
};
|
||||
const mapKey = `${entry.pluginId}\0${entry.key}`;
|
||||
const previous = settingsIndex.get(mapKey);
|
||||
// Keep a retained sidecar when it is strictly newer than the stored row
|
||||
// (e.g. schema-rejected remote apply left sidecar only).
|
||||
if (!previous || entry.updatedAt >= previous.updatedAt) {
|
||||
settingsIndex.set(mapKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(...settingsIndex.values());
|
||||
entries.sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
|
||||
return { version: 1, entries };
|
||||
}
|
||||
|
||||
function mergePluginSyncSidecars(params) {
|
||||
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
|
||||
const merged = new Map();
|
||||
|
||||
for (const entry of params.local ?? []) {
|
||||
if (!entry || typeof entry.pluginId !== "string" || !isPluginSyncSidecarKind(entry.kind)) continue;
|
||||
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
|
||||
pluginId: entry.pluginId,
|
||||
kind: entry.kind,
|
||||
key: String(entry.key),
|
||||
value: entry.value,
|
||||
updatedAt: Number(entry.updatedAt) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const raw of remoteEntries) {
|
||||
if (!raw || typeof raw.pluginId !== "string" || !isPluginSyncSidecarKind(raw.kind)) continue;
|
||||
if (raw.kind === "settings") {
|
||||
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
|
||||
if (declared) {
|
||||
const settingId = String(raw.key).split("\0")[0] ?? "";
|
||||
const field = declared.find((entry) => entry.id === settingId);
|
||||
if (field && !isCloudSyncablePluginSetting(field)) continue;
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
pluginId: raw.pluginId,
|
||||
kind: raw.kind,
|
||||
key: String(raw.key),
|
||||
value: raw.value,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
};
|
||||
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
|
||||
const previous = merged.get(mapKey);
|
||||
if (!previous || next.updatedAt >= previous.updatedAt) {
|
||||
merged.set(mapKey, next);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function excludeSecretPluginSettingsFromSidecars(entries, declaredSettingsByPlugin) {
|
||||
return entries.filter((entry) => {
|
||||
if (entry.kind !== "settings") return true;
|
||||
const declared = declaredSettingsByPlugin.get(entry.pluginId);
|
||||
if (!declared) return true;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) return false;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field) return true;
|
||||
return isCloudSyncablePluginSetting(field);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectPluginSyncSidecars,
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
isCloudSyncablePluginSetting,
|
||||
isPluginSyncSidecarKind,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
settingsSidecarKey,
|
||||
};
|
||||
410
electron/plugins/pluginSyncSidecarService.cjs
Normal file
410
electron/plugins/pluginSyncSidecarService.cjs
Normal file
@@ -0,0 +1,410 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Host-side collection and apply path for plugin encrypted sync sidecars.
|
||||
* Uses non-cascade tables so uninstall/missing plugins do not destroy data.
|
||||
*/
|
||||
|
||||
const {
|
||||
collectPluginSyncSidecars,
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
settingsSidecarKey,
|
||||
isCloudSyncablePluginSetting,
|
||||
} = require("./pluginSyncSidecarHelpers.cjs");
|
||||
|
||||
class PluginSyncSidecarService {
|
||||
constructor(options) {
|
||||
if (!options?.database) {
|
||||
throw new TypeError("Plugin sync sidecar service requires a database");
|
||||
}
|
||||
this.database = options.database;
|
||||
this.contributionService = options.contributionService ?? null;
|
||||
}
|
||||
|
||||
#declaredSettingsByPlugin() {
|
||||
const map = new Map();
|
||||
const snapshot = this.contributionService?.snapshot?.();
|
||||
const plugins = snapshot?.plugins ?? [];
|
||||
for (const plugin of plugins) {
|
||||
const fields = (plugin.settings ?? []).map((setting) => ({
|
||||
id: setting.id,
|
||||
secret: setting.secret === true,
|
||||
sync: setting.sync === true,
|
||||
scope: setting.scope,
|
||||
}));
|
||||
map.set(plugin.id, fields);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
|
||||
*/
|
||||
collectForSync() {
|
||||
const declared = this.#declaredSettingsByPlugin();
|
||||
const storedSettings = typeof this.database.listAllSettings === "function"
|
||||
? this.database.listAllSettings()
|
||||
: [];
|
||||
const existingSidecars = typeof this.database.listAllSyncSidecars === "function"
|
||||
? this.database.listAllSyncSidecars()
|
||||
: [];
|
||||
// Keep retained settings sidecars for installed plugins even when
|
||||
// plugin_settings has no row yet (downloaded while plugin was missing).
|
||||
// Do not write them into plugin_settings here — that would bypass the
|
||||
// contribution service's schema validation. applyFromSync / enable paths
|
||||
// rehydrate through updateSetting. User resets clear the sidecar row via
|
||||
// contributionService.resetSetting so deliberate deletions stay deleted.
|
||||
const filteredExisting = existingSidecars.filter((entry) => {
|
||||
if (entry.kind !== "settings") return true;
|
||||
if (!declared.has(entry.pluginId)) return true; // missing plugin — preserve
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) return false;
|
||||
const fields = declared.get(entry.pluginId) ?? [];
|
||||
const field = fields.find((item) => item.id === parsed.settingId);
|
||||
// Drop secret / non-sync declarations; unknown fields stay for schema lag.
|
||||
if (field && !isCloudSyncablePluginSetting(field)) return false;
|
||||
return true;
|
||||
});
|
||||
const bundle = collectPluginSyncSidecars({
|
||||
declaredSettingsByPlugin: declared,
|
||||
storedSettings,
|
||||
existingSidecars: filteredExisting,
|
||||
});
|
||||
bundle.entries = excludeSecretPluginSettingsFromSidecars(bundle.entries, declared);
|
||||
// Persist collected settings into the non-cascade table so a later uninstall
|
||||
// still has rows to re-emit (plugin_settings alone is not enough once the
|
||||
// declaration disappears and collection skips undeclared stored values).
|
||||
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
||||
this.database.replaceAllSyncSidecars(bundle.entries);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a remote sidecar bundle into local non-cascade storage.
|
||||
* Does not delete local rows for plugins absent from the remote bundle.
|
||||
* Installed-plugin settings go through the contribution service when available
|
||||
* so values are validated and runtime change events fire.
|
||||
*/
|
||||
async applyFromSync(remoteBundle) {
|
||||
const declared = this.#declaredSettingsByPlugin();
|
||||
const local = typeof this.database.listAllSyncSidecars === "function"
|
||||
? this.database.listAllSyncSidecars()
|
||||
: [];
|
||||
const remoteEntries = Array.isArray(remoteBundle?.entries) ? remoteBundle.entries : [];
|
||||
// Explicit empty remote bundle is an authoritative wipe (including rows for
|
||||
// missing plugins). Legacy payloads without pluginSidecars never reach here
|
||||
// (apply path only runs when the field is present).
|
||||
const remoteIsAuthoritativeEmpty = Array.isArray(remoteBundle?.entries)
|
||||
&& remoteBundle.entries.length === 0;
|
||||
// Remote is authoritative for installed plugins. For missing plugins, if the
|
||||
// remote bundle mentions the plugin at all, remote is authoritative for that
|
||||
// plugin's entire sidecar set (do not resurrect local-only keys another
|
||||
// device deleted). Only plugins completely absent from remote keep local
|
||||
// retention so first-time missing-plugin baselines are not wiped.
|
||||
const remotePluginIds = new Set(
|
||||
remoteEntries.map((entry) => entry.pluginId).filter((id) => typeof id === "string"),
|
||||
);
|
||||
const remoteKeys = new Set(
|
||||
remoteEntries.map((entry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`),
|
||||
);
|
||||
// Settings: remote is authoritative for installed plugins and for missing
|
||||
// plugins mentioned in the remote bundle. Baselines (account/crdt) always
|
||||
// enter merge so domain LWW can compare local vs remote; orphans survive.
|
||||
const preservedLocal = local.filter((entry) => {
|
||||
if (entry.kind !== "settings") {
|
||||
return true;
|
||||
}
|
||||
if (remoteIsAuthoritativeEmpty) return false;
|
||||
if (declared.has(entry.pluginId)) return false; // installed: remote wins
|
||||
if (remotePluginIds.has(entry.pluginId)) return false;
|
||||
return true;
|
||||
});
|
||||
const merged = mergePluginSyncSidecars({
|
||||
local: preservedLocal,
|
||||
remote: { version: 1, entries: remoteEntries },
|
||||
declaredSettingsByPlugin: declared,
|
||||
});
|
||||
const safe = excludeSecretPluginSettingsFromSidecars(merged, declared);
|
||||
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
||||
this.database.replaceAllSyncSidecars(safe);
|
||||
}
|
||||
// Drop installed-plugin settings that remote no longer carries.
|
||||
// Prefer contributionService.resetSetting so plugins and the renderer
|
||||
// receive change events; fall back to direct DB delete when unavailable.
|
||||
// Include live plugin_settings rows that were never mirrored into
|
||||
// plugin_sync_sidecars (e.g. pre-sidecar schema migrations) so remote
|
||||
// deletions still clear them instead of being republished on next collect.
|
||||
const resetCandidates = [];
|
||||
const seenResetKeys = new Set();
|
||||
const pushResetCandidate = (entry) => {
|
||||
const mapKey = `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
|
||||
if (seenResetKeys.has(mapKey)) return;
|
||||
seenResetKeys.add(mapKey);
|
||||
resetCandidates.push(entry);
|
||||
};
|
||||
for (const entry of local) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
if (!declared.has(entry.pluginId)) continue;
|
||||
if (remoteKeys.has(`${entry.pluginId}\0${entry.kind}\0${entry.key}`)) continue;
|
||||
pushResetCandidate(entry);
|
||||
}
|
||||
if (typeof this.database.listAllSettings === "function") {
|
||||
for (const row of this.database.listAllSettings()) {
|
||||
if (!declared.has(row.pluginId)) continue;
|
||||
const fields = declared.get(row.pluginId) ?? [];
|
||||
const field = fields.find((item) => item.id === row.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const key = settingsSidecarKey(row.settingId, row.scope, row.scopeId);
|
||||
if (remoteKeys.has(`${row.pluginId}\0settings\0${key}`)) continue;
|
||||
pushResetCandidate({
|
||||
pluginId: row.pluginId,
|
||||
kind: "settings",
|
||||
key,
|
||||
value: row.value,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const entry of resetCandidates) {
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
if (typeof this.contributionService?.resetSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.resetSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Validation rejected the reset (e.g. required setting) — still try
|
||||
// the stored-coordinate delete below for stale-scope rows.
|
||||
}
|
||||
// resetSetting normalizes against the *current* declared scope. After a
|
||||
// scope migration (application → device), that may miss the stale row
|
||||
// still stored under the old coordinates; always delete those too.
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the stored-coordinate row.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
|
||||
}
|
||||
for (const entry of safe) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
const fields = declared.get(entry.pluginId);
|
||||
if (!fields) continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
const field = fields.find((item) => item.id === parsed.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
// Always write under the currently declared scope/key coordinates.
|
||||
// Older sidecars may encode an obsolete scope after a plugin update;
|
||||
// recreating that scope would leave duplicate rows that collection
|
||||
// republishes. Application scope always uses the fixed "application" id.
|
||||
const target = resolveSettingsSidecarTarget(field, parsed);
|
||||
if (!target) continue;
|
||||
const { targetScope, targetScopeId, nextKey } = target;
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
entry.value,
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
|
||||
this.database.setSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
entry.pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid against current schema — keep sidecar row only.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
this.database.setSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
entry.pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a plugin is installed/enabled, validate and apply retained settings
|
||||
* sidecars into plugin_settings so the running plugin sees cloud values.
|
||||
*/
|
||||
async hydrateInstalledPluginSettings(pluginId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return;
|
||||
const declared = this.#declaredSettingsByPlugin().get(pluginId);
|
||||
if (!declared) return;
|
||||
const rows = typeof this.database.listSyncSidecars === "function"
|
||||
? this.database.listSyncSidecars(pluginId)
|
||||
: [];
|
||||
for (const entry of rows) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const target = resolveSettingsSidecarTarget(field, parsed);
|
||||
if (!target) continue;
|
||||
const { targetScope, targetScopeId, nextKey } = target;
|
||||
// Do not overwrite a newer local edit that has not been collected yet.
|
||||
if (typeof this.database.listSettings === "function") {
|
||||
const localRows = this.database.listSettings(pluginId);
|
||||
const local = localRows.find((row) => (
|
||||
row.settingId === parsed.settingId
|
||||
&& row.scope === targetScope
|
||||
&& row.scopeId === targetScopeId
|
||||
));
|
||||
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
entry.value,
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
this.database.setSetting(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid against current schema — keep sidecar only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an account or CRDT baseline without cascading on uninstall.
|
||||
*/
|
||||
setBaseline(pluginId, kind, key, value) {
|
||||
if (kind !== "account_baseline" && kind !== "crdt_baseline") {
|
||||
throw new TypeError("Baseline kind must be account_baseline or crdt_baseline");
|
||||
}
|
||||
this.database.setSyncSidecar(pluginId, kind, key, value);
|
||||
}
|
||||
|
||||
getBaseline(pluginId, kind, key) {
|
||||
return this.database.getSyncSidecar(pluginId, kind, key);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PluginSyncSidecarService,
|
||||
};
|
||||
512
electron/plugins/pluginSyncSidecarService.test.cjs
Normal file
512
electron/plugins/pluginSyncSidecarService.test.cjs
Normal file
@@ -0,0 +1,512 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
|
||||
function tempDb(context) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sidecar-"));
|
||||
context.after(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
return new PluginDatabase(path.join(dir, "plugins.sqlite"));
|
||||
}
|
||||
|
||||
test("sidecar table has no package cascade and survives removePlugin", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.installVersion({
|
||||
pluginId: "com.example.sync",
|
||||
version: "1.0.0",
|
||||
manifest: { id: "com.example.sync", version: "1.0.0" },
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "packages/com.example.sync/1.0.0",
|
||||
}, { enable: true });
|
||||
database.setSyncSidecar("com.example.sync", "account_baseline", "account", { id: "acct" }, 10);
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
|
||||
database.removePlugin("com.example.sync");
|
||||
|
||||
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
|
||||
pluginId: "com.example.sync",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct" },
|
||||
updatedAt: 10,
|
||||
});
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync excludes secrets and preserves missing-plugin baselines", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "replica", { clock: 2 }, 5);
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
database.setSetting("com.example.sync", "com.example.sync.token", "application", "application", "secret");
|
||||
|
||||
const contributionService = {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
{ id: "com.example.sync.token", secret: true, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const service = new PluginSyncSidecarService({ database, contributionService });
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.version, 1);
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
||||
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
|
||||
// Collected settings must land in the non-cascade table for post-uninstall collect.
|
||||
const persisted = database.listAllSyncSidecars();
|
||||
assert.ok(persisted.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
||||
assert.ok(persisted.some((entry) => entry.kind === "crdt_baseline"));
|
||||
});
|
||||
|
||||
test("collectForSync re-emits retained sidecars after plugin reinstall without writing settings", (context) => {
|
||||
const database = tempDb(context);
|
||||
// Sidecar applied while plugin was missing (no plugin_settings row).
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud",
|
||||
9,
|
||||
);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = service.collectForSync();
|
||||
assert.ok(bundle.entries.some((e) => e.value === "from-cloud"));
|
||||
// Collect must not bypass schema validation by writing plugin_settings.
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync re-emits settings after plugin uninstall via non-cascade table", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.installVersion({
|
||||
pluginId: "com.example.sync",
|
||||
version: "1.0.0",
|
||||
manifest: { id: "com.example.sync", version: "1.0.0" },
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: "packages/com.example.sync/1.0.0",
|
||||
}, { enable: true });
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
|
||||
const contributionService = {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
const service = new PluginSyncSidecarService({ database, contributionService });
|
||||
service.collectForSync();
|
||||
|
||||
database.removePlugin("com.example.sync");
|
||||
const missingService = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
const afterUninstall = missingService.collectForSync();
|
||||
assert.ok(
|
||||
afterUninstall.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"),
|
||||
"settings sidecar must survive uninstall via non-cascade persistence",
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync omits deleted settings for installed plugins but keeps missing-plugin rows", (context) => {
|
||||
const database = tempDb(context);
|
||||
// User reset clears both plugin_settings and the matching sidecar row.
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"stale",
|
||||
5,
|
||||
);
|
||||
database.deleteSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "keep", 5);
|
||||
// Only store a different setting for the installed plugin.
|
||||
database.setSetting("com.example.sync", "com.example.sync.other", "application", "application", "ok");
|
||||
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
{ id: "com.example.sync.other", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
|
||||
assert.ok(bundle.entries.some((e) => e.value === "ok"));
|
||||
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
|
||||
});
|
||||
|
||||
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
|
||||
const database = tempDb(context);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 42,
|
||||
}],
|
||||
});
|
||||
const rows = database.listAllSettings();
|
||||
const theme = rows.find((r) => r.settingId === "com.example.sync.theme");
|
||||
assert.equal(theme?.value, "dark");
|
||||
assert.equal(theme?.updatedAt, 42);
|
||||
});
|
||||
|
||||
test("applyFromSync does not drop local baselines for plugins absent remotely", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.local.only", "account_baseline", "account", { id: "local" }, 1);
|
||||
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.remote.plugin",
|
||||
kind: "settings",
|
||||
key: "com.remote.plugin.theme\0application\0application",
|
||||
value: "light",
|
||||
updatedAt: 2,
|
||||
}],
|
||||
});
|
||||
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
|
||||
});
|
||||
|
||||
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud",
|
||||
9,
|
||||
);
|
||||
const updates = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async updateSetting(pluginId, settingId, value, scopeId) {
|
||||
updates.push({ pluginId, settingId, value, scopeId });
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.hydrateInstalledPluginSettings("com.example.sync");
|
||||
assert.equal(updates.length, 1);
|
||||
assert.equal(updates[0].value, "from-cloud");
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"from-cloud",
|
||||
);
|
||||
});
|
||||
|
||||
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"local-newer",
|
||||
20,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud-stale",
|
||||
9,
|
||||
);
|
||||
const updates = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async updateSetting(pluginId, settingId, value, scopeId) {
|
||||
updates.push({ pluginId, settingId, value, scopeId });
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.hydrateInstalledPluginSettings("com.example.sync");
|
||||
assert.equal(updates.length, 0);
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"local-newer",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyFromSync empty remote clears installed settings without sidecar rows", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Pre-sidecar migration residue: setting exists, sidecar table empty.
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"stale-local",
|
||||
3,
|
||||
);
|
||||
const resets = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async resetSetting(pluginId, settingId, scopeId) {
|
||||
resets.push({ pluginId, settingId, scopeId });
|
||||
database.deleteSetting(pluginId, settingId, "application", scopeId);
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(resets.length, 1);
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("applyFromSync deletes stale-scope settings under stored coordinates", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Sidecar/key still encodes application scope, but declaration moved to device.
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"stale-scope",
|
||||
3,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"stale-scope",
|
||||
3,
|
||||
);
|
||||
const resets = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "device" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async resetSetting(pluginId, settingId, scopeId) {
|
||||
// Simulates normalizeScopeId against the *current* device scope.
|
||||
resets.push({ pluginId, settingId, scopeId });
|
||||
database.deleteSetting(pluginId, settingId, "device", scopeId === "application" ? "device" : scopeId);
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
"stale application-scope row must be deleted by stored-coordinate fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(database.listAllSyncSidecars().length, 0);
|
||||
});
|
||||
|
||||
test("applyFromSync does not resurrect missing-plugin keys deleted on another device", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Local still has a setting that remote deleted, plus we keep another remote key.
|
||||
database.setSyncSidecar(
|
||||
"com.missing.plugin",
|
||||
"settings",
|
||||
"com.missing.plugin.deleted\0application\0application",
|
||||
"stale",
|
||||
1,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.missing.plugin",
|
||||
"settings",
|
||||
"com.missing.plugin.kept\0application\0application",
|
||||
"local-old",
|
||||
1,
|
||||
);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
kind: "settings",
|
||||
key: "com.missing.plugin.kept\0application\0application",
|
||||
value: "remote-new",
|
||||
updatedAt: 5,
|
||||
}],
|
||||
});
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.equal(
|
||||
all.some((entry) => String(entry.key).includes("deleted")),
|
||||
false,
|
||||
"must not resurrect remote-deleted missing-plugin setting",
|
||||
);
|
||||
assert.ok(all.some((entry) => entry.value === "remote-new"));
|
||||
});
|
||||
|
||||
test("applyFromSync empty remote bundle preserves local baselines", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
||||
database.setSyncSidecar("com.missing.plugin", "account_baseline", "account", { id: "a1" }, 2);
|
||||
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "crdt", { clock: 9 }, 3);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.equal(all.some((entry) => entry.kind === "settings"), false);
|
||||
assert.equal(all.some((entry) => entry.kind === "account_baseline"), true);
|
||||
assert.equal(all.some((entry) => entry.kind === "crdt_baseline"), true);
|
||||
});
|
||||
|
||||
test("applyFromSync keeps newer local baseline over older remote (LWW)", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.example.plugin", "crdt_baseline", "crdt", { clock: 100 }, 100);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot: () => ({
|
||||
plugins: [{
|
||||
id: "com.example.plugin",
|
||||
settings: [],
|
||||
}],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.plugin",
|
||||
kind: "crdt_baseline",
|
||||
key: "crdt",
|
||||
value: { clock: 50 },
|
||||
updatedAt: 50,
|
||||
}],
|
||||
});
|
||||
const row = database.listAllSyncSidecars().find((entry) => entry.kind === "crdt_baseline");
|
||||
assert.ok(row);
|
||||
assert.equal(row.updatedAt, 100);
|
||||
assert.deepEqual(row.value, { clock: 100 });
|
||||
});
|
||||
288
electron/plugins/pluginViewHost.cjs
Normal file
288
electron/plugins/pluginViewHost.cjs
Normal file
@@ -0,0 +1,288 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const { assertJsonValue } = require("./contributionService.cjs");
|
||||
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
|
||||
|
||||
const VIEW_CHANNELS = Object.freeze({
|
||||
postMessage: "netcatty-plugin-view:post-message",
|
||||
executeCommand: "netcatty-plugin-view:execute-command",
|
||||
getState: "netcatty-plugin-view:get-state",
|
||||
getEnvironment: "netcatty-plugin-view:get-environment",
|
||||
setState: "netcatty-plugin-view:set-state",
|
||||
});
|
||||
|
||||
function normalizeBounds(bounds) {
|
||||
const value = bounds ?? { x: 0, y: 0, width: 480, height: 320 };
|
||||
const normalized = {};
|
||||
for (const key of ["x", "y", "width", "height"]) {
|
||||
if (!Number.isSafeInteger(value[key])) throw new TypeError("Plugin view bounds must use safe integers");
|
||||
normalized[key] = value[key];
|
||||
}
|
||||
if (normalized.width < 1 || normalized.height < 1 || normalized.width > 16_384 || normalized.height > 16_384) {
|
||||
throw new TypeError("Plugin view bounds are invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
class PluginViewHost {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.protocol = options.protocol;
|
||||
this.packageStore = options.packageStore;
|
||||
this.database = options.database;
|
||||
this.contributionService = options.contributionService;
|
||||
this.preloadPath = options.preloadPath ?? path.join(__dirname, "runtime", "viewPreload.cjs");
|
||||
this.instances = new Map();
|
||||
this.byWebContentsId = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.environment = { locale: "en", theme: "system", reducedMotion: false, highContrast: false };
|
||||
this.subscriptions = [];
|
||||
this.#registerIpc();
|
||||
this.subscriptions.push(this.contributionService.onDidPostViewMessage((event) => {
|
||||
for (const instance of this.instances.values()) {
|
||||
if (instance.pluginId === event.pluginId && instance.viewId === event.viewId) {
|
||||
instance.contents.send("netcatty-plugin-view:message", event.message);
|
||||
}
|
||||
}
|
||||
}));
|
||||
this.subscriptions.push(this.contributionService.onDidChange?.((event) => {
|
||||
if (!["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
|| !event.pluginId) return;
|
||||
for (const instance of [...this.instances.values()]) {
|
||||
if (instance.pluginId === event.pluginId) void this.close(instance.instanceId, undefined, event.reason);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
onDidClose(listener) {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
#registerIpc() {
|
||||
const ipcMain = this.electron.ipcMain;
|
||||
if (!ipcMain?.handle) return;
|
||||
const instanceFor = (event) => {
|
||||
const instance = this.byWebContentsId.get(event.sender?.id);
|
||||
if (!instance || instance.contents !== event.sender) throw new Error("Untrusted plugin view sender");
|
||||
return instance;
|
||||
};
|
||||
ipcMain.handle(VIEW_CHANNELS.postMessage, async (event, message) => {
|
||||
const instance = instanceFor(event);
|
||||
assertJsonValue(message, "view message");
|
||||
await this.contributionService.runtimeSupervisor.notify(instance.pluginId, "plugin.view.message", {
|
||||
viewId: instance.viewId,
|
||||
message,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.executeCommand, (event, payload) => {
|
||||
const instance = instanceFor(event);
|
||||
return this.contributionService.executeCommand(payload?.command, payload?.args, {
|
||||
source: "view",
|
||||
callerPluginId: instance.pluginId,
|
||||
context: { "netcatty.view": instance.viewId },
|
||||
});
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.getState, (event) => {
|
||||
const instance = instanceFor(event);
|
||||
return this.database.getViewState(instance.pluginId, instance.viewId, instance.scopeId);
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.getEnvironment, (event) => {
|
||||
instanceFor(event);
|
||||
return structuredClone(this.environment);
|
||||
});
|
||||
ipcMain.handle(VIEW_CHANNELS.setState, (event, state) => {
|
||||
const instance = instanceFor(event);
|
||||
assertJsonValue(state, "view state");
|
||||
this.database.setViewState(instance.pluginId, instance.viewId, instance.scopeId, state);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
#ownedInstance(instanceId, sender) {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) throw new Error(`Plugin view was not found: ${instanceId}`);
|
||||
if (sender) {
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (!owner || instance.owner !== owner) throw new Error("Plugin view belongs to another window");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async open(payload, sender) {
|
||||
if (!payload || typeof payload !== "object") throw new TypeError("Plugin view request is invalid");
|
||||
const { plugin, view, identity } = await this.contributionService.activateView(
|
||||
payload.viewId,
|
||||
{ context: payload.context },
|
||||
);
|
||||
const activation = Object.freeze({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
viewId: view.id,
|
||||
runtimeId: identity?.runtimeId ?? null,
|
||||
});
|
||||
if (!activation.runtimeId) throw new Error("Plugin view runtime identity is unavailable");
|
||||
const scopeId = typeof payload.scopeId === "string" && payload.scopeId.length <= 256 ? payload.scopeId : null;
|
||||
if (!scopeId) throw new TypeError("Plugin view scope ID is required");
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (!owner || owner.isDestroyed()) throw new Error("Plugin view owner is unavailable");
|
||||
const instanceId = typeof payload.instanceId === "string" && payload.instanceId.length <= 128
|
||||
? payload.instanceId
|
||||
: randomUUID();
|
||||
if (this.instances.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
const partition = `netcatty-plugin-view-${randomUUID()}`;
|
||||
const pluginSession = this.electron.session.fromPartition(partition, { cache: false });
|
||||
let registration;
|
||||
let sessionRegistration;
|
||||
try {
|
||||
registration = this.protocol.registerView({
|
||||
pluginId: plugin.id,
|
||||
packageRoot,
|
||||
entry: view.entry,
|
||||
});
|
||||
sessionRegistration = this.protocol.registerSession(pluginSession);
|
||||
await pluginSession.setProxy({
|
||||
mode: "fixed_servers",
|
||||
proxyRules: "http=127.0.0.1:9;https=127.0.0.1:9;socks=127.0.0.1:9",
|
||||
proxyBypassRules: "<-loopback>",
|
||||
});
|
||||
pluginSession.on("will-download", (event) => event.preventDefault());
|
||||
pluginSession.setPermissionCheckHandler?.(() => false);
|
||||
pluginSession.setPermissionRequestHandler?.((_contents, _permission, callback) => callback(false));
|
||||
pluginSession.setDevicePermissionHandler?.(() => false);
|
||||
pluginSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
try {
|
||||
const url = new URL(details.url);
|
||||
callback({ cancel: url.protocol !== `${PLUGIN_PROTOCOL_SCHEME}:` || url.hostname !== registration.token });
|
||||
} catch { callback({ cancel: true }); }
|
||||
});
|
||||
const contentsView = new this.electron.WebContentsView({
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
devTools: false,
|
||||
disableDialogs: true,
|
||||
navigateOnDragDrop: false,
|
||||
session: pluginSession,
|
||||
preload: this.preloadPath,
|
||||
},
|
||||
});
|
||||
const contents = contentsView.webContents;
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.on("will-navigate", (event, url) => {
|
||||
if (url !== registration.url) event.preventDefault();
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
const ownerClosed = () => { void this.close(instanceId, undefined, "owner-closed"); };
|
||||
const instance = {
|
||||
instanceId,
|
||||
pluginId: plugin.id,
|
||||
viewId: view.id,
|
||||
scopeId,
|
||||
owner,
|
||||
ownerClosed,
|
||||
view: contentsView,
|
||||
contents,
|
||||
registration,
|
||||
sessionRegistration,
|
||||
state: "opening",
|
||||
attached: false,
|
||||
};
|
||||
this.instances.set(instanceId, instance);
|
||||
this.byWebContentsId.set(contents.id, instance);
|
||||
owner.once?.("closed", ownerClosed);
|
||||
contents.once("destroyed", () => { void this.close(instanceId, undefined, "contents-destroyed"); });
|
||||
await contents.loadURL(registration.url);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
if (this.instances.get(instanceId) !== instance || instance.state !== "opening"
|
||||
|| owner.isDestroyed() || contents.isDestroyed()) {
|
||||
throw new Error("Plugin view ownership changed while opening");
|
||||
}
|
||||
owner.contentView.addChildView(contentsView);
|
||||
instance.attached = true;
|
||||
contentsView.setBounds(normalizeBounds(payload.bounds));
|
||||
instance.state = "open";
|
||||
contents.send("netcatty-plugin-view:environment", this.environment);
|
||||
return { instanceId };
|
||||
} catch (error) {
|
||||
if (this.instances.has(instanceId)) await this.close(instanceId, undefined, "open-failed");
|
||||
else {
|
||||
try { registration?.dispose(); } catch {}
|
||||
try { sessionRegistration?.dispose(); } catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setBounds(instanceId, bounds, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
instance.view.setBounds(normalizeBounds(bounds));
|
||||
}
|
||||
|
||||
setVisible(instanceId, visible, sender) {
|
||||
if (typeof visible !== "boolean") throw new TypeError("Plugin view visibility must be a boolean");
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
instance.view.setVisible(visible);
|
||||
}
|
||||
|
||||
async postMessage(instanceId, message, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
assertJsonValue(message, "view message");
|
||||
instance.contents.send("netcatty-plugin-view:message", message);
|
||||
}
|
||||
|
||||
setEnvironment(environment) {
|
||||
assertJsonValue(environment, "environment");
|
||||
this.environment = { ...environment };
|
||||
for (const instance of this.instances.values()) {
|
||||
instance.contents.send("netcatty-plugin-view:environment", this.environment);
|
||||
}
|
||||
}
|
||||
|
||||
async close(instanceId, sender, reason = sender ? "renderer-requested" : "host-requested") {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
if (sender) this.#ownedInstance(instanceId, sender);
|
||||
this.instances.delete(instanceId);
|
||||
this.byWebContentsId.delete(instance.contents.id);
|
||||
try { instance.owner.removeListener?.("closed", instance.ownerClosed); } catch {}
|
||||
if (instance.attached) {
|
||||
try { instance.owner.contentView.removeChildView(instance.view); } catch {}
|
||||
}
|
||||
try { instance.registration.dispose(); } catch {}
|
||||
try { instance.sessionRegistration.dispose(); } catch {}
|
||||
try {
|
||||
if (!instance.contents.isDestroyed()) {
|
||||
if (typeof instance.contents.close === "function") instance.contents.close();
|
||||
else instance.contents.destroy?.();
|
||||
}
|
||||
} catch {}
|
||||
const event = Object.freeze({
|
||||
instanceId: instance.instanceId,
|
||||
pluginId: instance.pluginId,
|
||||
viewId: instance.viewId,
|
||||
reason,
|
||||
});
|
||||
for (const listener of this.closeListeners) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
await Promise.all([...this.instances.keys()].map((instanceId) => this.close(instanceId)));
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription?.dispose?.();
|
||||
this.closeListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginViewHost, VIEW_CHANNELS, normalizeBounds };
|
||||
343
electron/plugins/pluginViewHost.test.cjs
Normal file
343
electron/plugins/pluginViewHost.test.cjs
Normal file
@@ -0,0 +1,343 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginViewHost, normalizeBounds } = require("./pluginViewHost.cjs");
|
||||
|
||||
function fixture(options = {}) {
|
||||
const ipcHandlers = new Map();
|
||||
const senderOwners = new Map();
|
||||
const sessions = [];
|
||||
const views = [];
|
||||
let nextContentsId = 10;
|
||||
class FakeContents extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.id = nextContentsId++;
|
||||
this.sent = [];
|
||||
this.destroyed = false;
|
||||
}
|
||||
setWindowOpenHandler(handler) { this.windowOpenHandler = handler; }
|
||||
async loadURL(url) {
|
||||
this.loadedUrl = url;
|
||||
if (options.loadURL) await options.loadURL(this);
|
||||
}
|
||||
send(channel, payload) { this.sent.push([channel, payload]); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
close() { this.destroyed = true; this.emit("destroyed"); }
|
||||
}
|
||||
class FakeWebContentsView {
|
||||
constructor(optionsValue) {
|
||||
this.options = optionsValue;
|
||||
this.webContents = new FakeContents();
|
||||
views.push(this);
|
||||
}
|
||||
setBounds(bounds) { this.bounds = bounds; }
|
||||
setVisible(visible) { this.visible = visible; }
|
||||
}
|
||||
const electron = {
|
||||
ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); } },
|
||||
BrowserWindow: { fromWebContents(sender) { return senderOwners.get(sender) ?? null; } },
|
||||
WebContentsView: FakeWebContentsView,
|
||||
session: {
|
||||
fromPartition(partition, sessionOptions) {
|
||||
const pluginSession = new EventEmitter();
|
||||
pluginSession.partition = partition;
|
||||
pluginSession.options = sessionOptions;
|
||||
pluginSession.permissions = {};
|
||||
pluginSession.webRequest = {
|
||||
onBeforeRequest(handler) { pluginSession.beforeRequest = handler; },
|
||||
};
|
||||
pluginSession.setProxy = options.setProxy ?? (async (proxy) => { pluginSession.proxy = proxy; });
|
||||
pluginSession.setPermissionCheckHandler = (handler) => { pluginSession.permissions.check = handler; };
|
||||
pluginSession.setPermissionRequestHandler = (handler) => { pluginSession.permissions.request = handler; };
|
||||
pluginSession.setDevicePermissionHandler = (handler) => { pluginSession.permissions.device = handler; };
|
||||
sessions.push(pluginSession);
|
||||
return pluginSession;
|
||||
},
|
||||
},
|
||||
};
|
||||
const disposals = [];
|
||||
const protocol = {
|
||||
registerView() {
|
||||
return {
|
||||
token: "view-token",
|
||||
url: "netcatty-plugin://view-token/package/view.html",
|
||||
dispose() {
|
||||
disposals.push("view");
|
||||
if (options.disposeViewThrows) throw new Error("view cleanup failed");
|
||||
},
|
||||
};
|
||||
},
|
||||
registerSession() {
|
||||
return { dispose() {
|
||||
disposals.push("session");
|
||||
if (options.disposeSessionThrows) throw new Error("session cleanup failed");
|
||||
} };
|
||||
},
|
||||
};
|
||||
let viewListener;
|
||||
let changeListener;
|
||||
let activationCurrent = true;
|
||||
let activationChecks = 0;
|
||||
const contributionService = {
|
||||
runtimeSupervisor: { async notify(...args) { return args; } },
|
||||
onDidPostViewMessage(listener) { viewListener = listener; return { dispose() {} }; },
|
||||
onDidChange(listener) { changeListener = listener; return { dispose() {} }; },
|
||||
async activateView() {
|
||||
return {
|
||||
plugin: { id: "com.example.view", activeVersion: "1.0.0" },
|
||||
view: { id: "com.example.view.panel", entry: "view.html", location: "aside" },
|
||||
identity: { runtimeId: "runtime-view-1", pluginVersion: "1.0.0" },
|
||||
};
|
||||
},
|
||||
assertViewActivationCurrent(expected) {
|
||||
activationChecks += 1;
|
||||
if (!activationCurrent) throw new Error("Plugin view ownership changed while opening");
|
||||
assert.deepEqual(expected, {
|
||||
pluginId: "com.example.view",
|
||||
pluginVersion: "1.0.0",
|
||||
viewId: "com.example.view.panel",
|
||||
runtimeId: "runtime-view-1",
|
||||
});
|
||||
return expected;
|
||||
},
|
||||
async executeCommand(command, args, invocation) { return { command, args, invocation }; },
|
||||
};
|
||||
const state = new Map();
|
||||
const host = new PluginViewHost({
|
||||
electron,
|
||||
protocol,
|
||||
packageStore: {
|
||||
async preparePackageRoot() {
|
||||
if (options.preparePackageRoot) await options.preparePackageRoot();
|
||||
return "/package";
|
||||
},
|
||||
},
|
||||
database: {
|
||||
getViewState(pluginId, viewId, scopeId) { return state.get(`${pluginId}:${viewId}:${scopeId}`); },
|
||||
setViewState(pluginId, viewId, scopeId, value) { state.set(`${pluginId}:${viewId}:${scopeId}`, value); },
|
||||
},
|
||||
contributionService,
|
||||
preloadPath: "/runtime/viewPreload.cjs",
|
||||
});
|
||||
function createOwner() {
|
||||
const owner = new EventEmitter();
|
||||
owner.children = [];
|
||||
owner.isDestroyed = () => false;
|
||||
owner.contentView = {
|
||||
addChildView(view) { owner.children.push(view); },
|
||||
removeChildView(view) { owner.children = owner.children.filter((entry) => entry !== view); },
|
||||
};
|
||||
const sender = {};
|
||||
senderOwners.set(sender, owner);
|
||||
return { owner, sender };
|
||||
}
|
||||
return {
|
||||
changeListener: () => changeListener,
|
||||
contributionService,
|
||||
createOwner,
|
||||
disposals,
|
||||
host,
|
||||
ipcHandlers,
|
||||
sessions,
|
||||
activationChecks: () => activationChecks,
|
||||
setActivationCurrent(value) { activationCurrent = value; },
|
||||
viewListener: () => viewListener,
|
||||
views,
|
||||
};
|
||||
}
|
||||
|
||||
test("plugin view bounds reject unsafe or oversized geometry", () => {
|
||||
assert.deepEqual(normalizeBounds({ x: 1, y: 2, width: 640, height: 480 }), { x: 1, y: 2, width: 640, height: 480 });
|
||||
assert.throws(() => normalizeBounds({ x: 0.5, y: 0, width: 1, height: 1 }), /safe integers/u);
|
||||
assert.throws(() => normalizeBounds({ x: 0, y: 0, width: 20_000, height: 1 }), /invalid/u);
|
||||
});
|
||||
|
||||
test("custom views use an ephemeral sandbox and deny direct browser capabilities", async () => {
|
||||
const value = fixture();
|
||||
const { owner, sender } = value.createOwner();
|
||||
const opened = await value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
instanceId: "view-1",
|
||||
bounds: { x: 5, y: 6, width: 700, height: 500 },
|
||||
}, sender);
|
||||
assert.deepEqual(opened, { instanceId: "view-1" });
|
||||
const contentsView = value.views[0];
|
||||
assert.deepEqual(contentsView.options.webPreferences, {
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
devTools: false,
|
||||
disableDialogs: true,
|
||||
navigateOnDragDrop: false,
|
||||
session: value.sessions[0],
|
||||
preload: "/runtime/viewPreload.cjs",
|
||||
});
|
||||
assert.match(value.sessions[0].partition, /^netcatty-plugin-view-/u);
|
||||
assert.deepEqual(value.sessions[0].options, { cache: false });
|
||||
assert.equal(value.sessions[0].permissions.check(), false);
|
||||
assert.equal(value.sessions[0].permissions.device(), false);
|
||||
let permissionDecision;
|
||||
value.sessions[0].permissions.request(null, "camera", (decision) => { permissionDecision = decision; });
|
||||
assert.equal(permissionDecision, false);
|
||||
let allowed;
|
||||
value.sessions[0].beforeRequest({ url: "netcatty-plugin://view-token/package/view.js" }, (decision) => { allowed = decision; });
|
||||
assert.deepEqual(allowed, { cancel: false });
|
||||
value.sessions[0].beforeRequest({ url: "https://example.com/track" }, (decision) => { allowed = decision; });
|
||||
assert.deepEqual(allowed, { cancel: true });
|
||||
assert.deepEqual(contentsView.webContents.windowOpenHandler(), { action: "deny" });
|
||||
const navigation = { prevented: false, preventDefault() { this.prevented = true; } };
|
||||
contentsView.webContents.emit("will-navigate", navigation, "https://example.com");
|
||||
assert.equal(navigation.prevented, true);
|
||||
assert.deepEqual(contentsView.bounds, { x: 5, y: 6, width: 700, height: 500 });
|
||||
assert.equal(owner.children.length, 1);
|
||||
assert.equal(value.activationChecks(), 2);
|
||||
const environment = {
|
||||
locale: "zh-CN",
|
||||
theme: "dark",
|
||||
reducedMotion: true,
|
||||
highContrast: false,
|
||||
themeTokens: { "--background": "220 10% 10%" },
|
||||
};
|
||||
value.host.setEnvironment(environment);
|
||||
assert.deepEqual(await value.ipcHandlers.get("netcatty-plugin-view:get-environment")(
|
||||
{ sender: contentsView.webContents },
|
||||
), environment);
|
||||
value.host.setVisible("view-1", false, sender);
|
||||
assert.equal(contentsView.visible, false);
|
||||
assert.deepEqual(await value.ipcHandlers.get("netcatty-plugin-view:execute-command")(
|
||||
{ sender: contentsView.webContents },
|
||||
{ command: "com.example.view.run", args: { selected: 1 } },
|
||||
), {
|
||||
command: "com.example.view.run",
|
||||
args: { selected: 1 },
|
||||
invocation: {
|
||||
source: "view",
|
||||
callerPluginId: "com.example.view",
|
||||
context: { "netcatty.view": "com.example.view.panel" },
|
||||
},
|
||||
});
|
||||
|
||||
const other = value.createOwner();
|
||||
assert.throws(() => value.host.setVisible("view-1", true, other.sender), /another window/u);
|
||||
assert.throws(() => value.host.setBounds("view-1", { x: 0, y: 0, width: 1, height: 1 }, other.sender), /another window/u);
|
||||
await assert.rejects(value.host.close("view-1", other.sender), /another window/u);
|
||||
owner.emit("closed");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.deepEqual(value.disposals.sort(), ["session", "view"]);
|
||||
});
|
||||
|
||||
test("view setup failures dispose protocol registrations before exposing an instance", async () => {
|
||||
const value = fixture({ setProxy: async () => { throw new Error("proxy unavailable"); } });
|
||||
const { sender } = value.createOwner();
|
||||
await assert.rejects(value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
bounds: { x: 0, y: 0, width: 10, height: 10 },
|
||||
}, sender), /proxy unavailable/u);
|
||||
assert.deepEqual(value.disposals.sort(), ["session", "view"]);
|
||||
assert.equal(value.views.length, 0);
|
||||
});
|
||||
|
||||
test("terminal runtime states close every open view owned by the plugin", async () => {
|
||||
for (const reason of ["runtime-stopped", "runtime-error", "runtime-quarantined"]) {
|
||||
const value = fixture();
|
||||
const { owner, sender } = value.createOwner();
|
||||
await value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
instanceId: `view-${reason}`,
|
||||
bounds: { x: 0, y: 0, width: 320, height: 240 },
|
||||
}, sender);
|
||||
assert.equal(owner.children.length, 1);
|
||||
|
||||
value.changeListener()({ reason, pluginId: "com.example.view" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.deepEqual(value.disposals.sort(), ["session", "view"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin replacement during package preparation cannot publish a stale view", async () => {
|
||||
let releasePackage;
|
||||
const packageReady = new Promise((resolve) => { releasePackage = resolve; });
|
||||
const value = fixture({ preparePackageRoot: () => packageReady });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const opening = value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
instanceId: "view-replaced",
|
||||
bounds: { x: 0, y: 0, width: 320, height: 240 },
|
||||
}, sender);
|
||||
value.setActivationCurrent(false);
|
||||
releasePackage();
|
||||
|
||||
await assert.rejects(opening, /ownership changed/u);
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views.length, 0);
|
||||
});
|
||||
|
||||
test("runtime shutdown cancels an in-flight view before it attaches and publishes a close event", async () => {
|
||||
let releaseLoad;
|
||||
const loadReady = new Promise((resolve) => { releaseLoad = resolve; });
|
||||
const value = fixture({ loadURL: () => loadReady });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const closed = [];
|
||||
value.host.onDidClose((event) => closed.push(event));
|
||||
const opening = value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
instanceId: "view-opening",
|
||||
bounds: { x: 0, y: 0, width: 320, height: 240 },
|
||||
}, sender);
|
||||
while (value.views.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(owner.children.length, 0);
|
||||
|
||||
value.changeListener()({ reason: "runtime-stopped", pluginId: "com.example.view" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
releaseLoad();
|
||||
|
||||
await assert.rejects(opening, /ownership changed/u);
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
assert.deepEqual(closed, [{
|
||||
instanceId: "view-opening",
|
||||
pluginId: "com.example.view",
|
||||
viewId: "com.example.view.panel",
|
||||
reason: "runtime-stopped",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("view cleanup failures cannot suppress owner removal or the renderer close signal", async () => {
|
||||
const value = fixture({ disposeViewThrows: true, disposeSessionThrows: true });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const closed = [];
|
||||
value.host.onDidClose((event) => closed.push(event));
|
||||
await value.host.open({
|
||||
viewId: "com.example.view.panel",
|
||||
scopeId: "window-1",
|
||||
instanceId: "view-cleanup-failure",
|
||||
bounds: { x: 0, y: 0, width: 320, height: 240 },
|
||||
}, sender);
|
||||
|
||||
await value.host.close("view-cleanup-failure", sender);
|
||||
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
assert.deepEqual(value.disposals, ["view", "session"]);
|
||||
assert.deepEqual(closed, [{
|
||||
instanceId: "view-cleanup-failure",
|
||||
pluginId: "com.example.view",
|
||||
viewId: "com.example.view.panel",
|
||||
reason: "renderer-requested",
|
||||
}]);
|
||||
});
|
||||
215
electron/plugins/quotaManager.cjs
Normal file
215
electron/plugins/quotaManager.cjs
Normal file
@@ -0,0 +1,215 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const DEFAULT_QUOTAS = Object.freeze({
|
||||
messageBurst: 512,
|
||||
messagesPerSecond: 256,
|
||||
concurrentCapabilities: 32,
|
||||
capabilityBurst: 120,
|
||||
capabilitiesPerMinute: 600,
|
||||
logWritesPerMinute: 240,
|
||||
bytesPerMinute: 32 * 1024 * 1024,
|
||||
memoryBytes: 256 * 1024 * 1024,
|
||||
cpuPercent: 80,
|
||||
sustainedCpuSamples: 3,
|
||||
sampleIntervalMs: 5_000,
|
||||
});
|
||||
|
||||
function quotaError(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.resourceExhausted, message, {
|
||||
pluginCode: "resource_exhausted",
|
||||
});
|
||||
}
|
||||
|
||||
class TokenBucket {
|
||||
constructor(capacity, refillPerMs, clock) {
|
||||
this.capacity = capacity;
|
||||
this.refillPerMs = refillPerMs;
|
||||
this.clock = clock;
|
||||
this.tokens = capacity;
|
||||
this.updatedAt = clock();
|
||||
}
|
||||
|
||||
consume(amount = 1) {
|
||||
const now = this.clock();
|
||||
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.updatedAt) * this.refillPerMs));
|
||||
this.updatedAt = now;
|
||||
if (this.tokens < amount) return false;
|
||||
this.tokens -= amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class PluginQuotaManager {
|
||||
constructor(options = {}) {
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
this.quotas = Object.freeze({ ...DEFAULT_QUOTAS, ...(options.quotas ?? {}) });
|
||||
this.getProcessMetrics = options.getProcessMetrics ?? null;
|
||||
this.onViolation = options.onViolation ?? (() => {});
|
||||
this.setInterval = options.setInterval ?? setInterval;
|
||||
this.clearInterval = options.clearInterval ?? clearInterval;
|
||||
this.messageBuckets = new Map();
|
||||
this.capabilityBuckets = new Map();
|
||||
this.logBuckets = new Map();
|
||||
this.concurrent = new Map();
|
||||
this.byteWindows = new Map();
|
||||
this.monitors = new Map();
|
||||
}
|
||||
|
||||
setViolationHandler(handler) {
|
||||
if (typeof handler !== "function") throw new TypeError("Plugin quota violation handler must be a function");
|
||||
this.onViolation = handler;
|
||||
}
|
||||
|
||||
#bucket(collection, key, capacity, refillPerMs) {
|
||||
let bucket = collection.get(key);
|
||||
if (!bucket) {
|
||||
bucket = new TokenBucket(capacity, refillPerMs, this.clock);
|
||||
collection.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
guardMessage(identity) {
|
||||
const bucket = this.#bucket(
|
||||
this.messageBuckets,
|
||||
identity.runtimeId,
|
||||
this.quotas.messageBurst,
|
||||
this.quotas.messagesPerSecond / 1_000,
|
||||
);
|
||||
if (!bucket.consume()) throw quotaError("Plugin runtime message rate exceeded");
|
||||
}
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
const runtimeId = context.runtimeId;
|
||||
const current = this.concurrent.get(runtimeId) ?? 0;
|
||||
if (current >= this.quotas.concurrentCapabilities) {
|
||||
throw quotaError("Plugin concurrent capability limit exceeded");
|
||||
}
|
||||
const capabilityBucket = this.#bucket(
|
||||
this.capabilityBuckets,
|
||||
runtimeId,
|
||||
this.quotas.capabilityBurst,
|
||||
this.quotas.capabilitiesPerMinute / 60_000,
|
||||
);
|
||||
if (!capabilityBucket.consume()) throw quotaError("Plugin capability request rate exceeded");
|
||||
if (context.method === "log.write") {
|
||||
const logBucket = this.#bucket(
|
||||
this.logBuckets,
|
||||
runtimeId,
|
||||
this.quotas.logWritesPerMinute,
|
||||
this.quotas.logWritesPerMinute / 60_000,
|
||||
);
|
||||
if (!logBucket.consume()) throw quotaError("Plugin log rate exceeded");
|
||||
}
|
||||
this.concurrent.set(runtimeId, current + 1);
|
||||
try { return await next(); }
|
||||
finally {
|
||||
const remaining = (this.concurrent.get(runtimeId) ?? 1) - 1;
|
||||
if (remaining <= 0) this.concurrent.delete(runtimeId);
|
||||
else this.concurrent.set(runtimeId, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
chargeBytes(runtimeId, category, byteLength) {
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 0) throw new TypeError("Plugin quota bytes are invalid");
|
||||
const key = `${runtimeId}\0${category}`;
|
||||
const now = this.clock();
|
||||
let window = this.byteWindows.get(key);
|
||||
if (!window || now - window.startedAt >= 60_000) {
|
||||
window = { startedAt: now, bytes: 0 };
|
||||
this.byteWindows.set(key, window);
|
||||
}
|
||||
window.bytes += byteLength;
|
||||
if (window.bytes > this.quotas.bytesPerMinute) {
|
||||
throw quotaError(`Plugin ${category} byte quota exceeded`);
|
||||
}
|
||||
}
|
||||
|
||||
trackRuntime(identity, runtime) {
|
||||
return this.trackProcess(identity.runtimeId, identity, runtime);
|
||||
}
|
||||
|
||||
trackProcess(resourceId, identity, processHandle) {
|
||||
this.releaseProcess(resourceId);
|
||||
if (!this.getProcessMetrics || typeof processHandle.getProcessId !== "function") {
|
||||
return Object.freeze({ dispose() {} });
|
||||
}
|
||||
let disposed = false;
|
||||
let sampling = false;
|
||||
let sustainedCpu = 0;
|
||||
const sample = async () => {
|
||||
if (disposed || sampling) return;
|
||||
sampling = true;
|
||||
try {
|
||||
const pid = processHandle.getProcessId();
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
||||
const metrics = await this.getProcessMetrics(pid);
|
||||
if (!metrics) return;
|
||||
if (metrics.memoryBytes > this.quotas.memoryBytes) {
|
||||
disposed = true;
|
||||
this.clearInterval(timer);
|
||||
await this.onViolation(identity, quotaError("Plugin memory quota exceeded"));
|
||||
return;
|
||||
}
|
||||
sustainedCpu = metrics.cpuPercent > this.quotas.cpuPercent ? sustainedCpu + 1 : 0;
|
||||
if (sustainedCpu >= this.quotas.sustainedCpuSamples) {
|
||||
disposed = true;
|
||||
this.clearInterval(timer);
|
||||
await this.onViolation(identity, quotaError("Plugin sustained CPU quota exceeded"));
|
||||
}
|
||||
} catch {
|
||||
// Metrics are advisory. Capability and transport quotas remain fail-closed.
|
||||
} finally {
|
||||
sampling = false;
|
||||
}
|
||||
};
|
||||
const timer = this.setInterval(sample, this.quotas.sampleIntervalMs);
|
||||
timer.unref?.();
|
||||
const monitor = Object.freeze({
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
this.clearInterval(timer);
|
||||
},
|
||||
});
|
||||
this.monitors.set(resourceId, monitor);
|
||||
void sample();
|
||||
return monitor;
|
||||
}
|
||||
|
||||
releaseProcess(resourceId) {
|
||||
this.monitors.get(resourceId)?.dispose();
|
||||
this.monitors.delete(resourceId);
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const resourceId of [...this.monitors.keys()]) {
|
||||
if (resourceId === runtimeId || resourceId.startsWith(`${runtimeId}\0`)) {
|
||||
this.releaseProcess(resourceId);
|
||||
}
|
||||
}
|
||||
this.messageBuckets.delete(runtimeId);
|
||||
this.capabilityBuckets.delete(runtimeId);
|
||||
this.logBuckets.delete(runtimeId);
|
||||
this.concurrent.delete(runtimeId);
|
||||
for (const key of [...this.byteWindows.keys()]) {
|
||||
if (key.startsWith(`${runtimeId}\0`)) this.byteWindows.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const monitor of this.monitors.values()) monitor.dispose();
|
||||
this.monitors.clear();
|
||||
this.messageBuckets.clear();
|
||||
this.capabilityBuckets.clear();
|
||||
this.logBuckets.clear();
|
||||
this.concurrent.clear();
|
||||
this.byteWindows.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_QUOTAS, PluginQuotaManager, TokenBucket, quotaError };
|
||||
92
electron/plugins/quotaManager.test.cjs
Normal file
92
electron/plugins/quotaManager.test.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginQuotaManager } = require("./quotaManager.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
test("transport, capability, log, concurrency, and byte quotas fail closed and refill", async () => {
|
||||
let now = 0;
|
||||
const manager = new PluginQuotaManager({
|
||||
clock: () => now,
|
||||
quotas: {
|
||||
messageBurst: 2,
|
||||
messagesPerSecond: 1,
|
||||
concurrentCapabilities: 1,
|
||||
capabilityBurst: 2,
|
||||
capabilitiesPerMinute: 1,
|
||||
logWritesPerMinute: 1,
|
||||
bytesPerMinute: 4,
|
||||
},
|
||||
});
|
||||
const identity = { runtimeId: "runtime-1" };
|
||||
manager.guardMessage(identity);
|
||||
manager.guardMessage(identity);
|
||||
assert.throws(() => manager.guardMessage(identity), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
now += 1_000;
|
||||
manager.guardMessage(identity);
|
||||
|
||||
const middleware = manager.createMiddleware();
|
||||
let release;
|
||||
const pending = middleware({ runtimeId: "runtime-1", method: "network.request" }, () => (
|
||||
new Promise((resolve) => { release = resolve; })
|
||||
));
|
||||
await assert.rejects(
|
||||
middleware({ runtimeId: "runtime-1", method: "network.request" }, async () => null),
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
release();
|
||||
await pending;
|
||||
|
||||
await middleware({ runtimeId: "runtime-log", method: "log.write" }, async () => null);
|
||||
await assert.rejects(
|
||||
middleware({ runtimeId: "runtime-log", method: "log.write" }, async () => null),
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
|
||||
manager.chargeBytes("runtime-1", "network", 4);
|
||||
assert.throws(
|
||||
() => manager.chargeBytes("runtime-1", "network", 1),
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
now += 60_000;
|
||||
manager.chargeBytes("runtime-1", "network", 4);
|
||||
manager.shutdown();
|
||||
});
|
||||
|
||||
test("runtime process quotas report sustained CPU and memory violations", async () => {
|
||||
const samples = [];
|
||||
const cleared = [];
|
||||
const violations = [];
|
||||
let metrics = { memoryBytes: 0, cpuPercent: 90 };
|
||||
const manager = new PluginQuotaManager({
|
||||
getProcessMetrics: async () => metrics,
|
||||
onViolation: async (_identity, error) => violations.push(error.message),
|
||||
quotas: { sustainedCpuSamples: 2, cpuPercent: 80, memoryBytes: 100 },
|
||||
setInterval: (callback) => { samples.push(callback); return { id: samples.length }; },
|
||||
clearInterval: (timer) => cleared.push(timer),
|
||||
});
|
||||
const runtime = { getProcessId: () => 123 };
|
||||
manager.trackRuntime({ runtimeId: "runtime-1" }, runtime);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await samples[0]();
|
||||
assert.deepEqual(violations, ["Plugin sustained CPU quota exceeded"]);
|
||||
assert.equal(cleared.length, 1);
|
||||
|
||||
metrics = { memoryBytes: 101, cpuPercent: 0 };
|
||||
manager.trackRuntime({ runtimeId: "runtime-2" }, runtime);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(violations, [
|
||||
"Plugin sustained CPU quota exceeded",
|
||||
"Plugin memory quota exceeded",
|
||||
]);
|
||||
manager.trackProcess(
|
||||
"runtime-3\0companion:one",
|
||||
{ runtimeId: "runtime-3" },
|
||||
runtime,
|
||||
);
|
||||
manager.releaseRuntime("runtime-3");
|
||||
assert.equal(cleared.length, 3, "runtime release disposes companion process monitors");
|
||||
manager.shutdown();
|
||||
});
|
||||
163
electron/plugins/restrictedJsonSchema.cjs
Normal file
163
electron/plugins/restrictedJsonSchema.cjs
Normal file
@@ -0,0 +1,163 @@
|
||||
"use strict";
|
||||
|
||||
const Ajv2020 = require("ajv/dist/2020").default;
|
||||
|
||||
const MAX_SCHEMA_DEPTH = 8;
|
||||
const MAX_SCHEMA_NODES = 256;
|
||||
const MAX_ENUM_ITEMS = 256;
|
||||
const MAX_PROPERTY_NAME_LENGTH = 128;
|
||||
const FORBIDDEN_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
||||
const ALLOWED_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]);
|
||||
const ALLOWED_KEYWORDS = new Set([
|
||||
"additionalProperties",
|
||||
"const",
|
||||
"enum",
|
||||
"items",
|
||||
"maxItems",
|
||||
"maxLength",
|
||||
"maximum",
|
||||
"minItems",
|
||||
"minLength",
|
||||
"minimum",
|
||||
"properties",
|
||||
"required",
|
||||
"type",
|
||||
"writeOnly",
|
||||
]);
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: true,
|
||||
validateFormats: false,
|
||||
});
|
||||
|
||||
function isPlainRecord(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function assertSafeName(name, label) {
|
||||
if (typeof name !== "string" || name.length < 1 || name.length > MAX_PROPERTY_NAME_LENGTH
|
||||
|| FORBIDDEN_PROPERTY_NAMES.has(name) || name.includes("\0")) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIntegerKeyword(schema, name) {
|
||||
if (schema[name] === undefined) return;
|
||||
if (!Number.isSafeInteger(schema[name]) || schema[name] < 0) {
|
||||
throw new TypeError(`Restricted setting schema ${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNumberKeyword(schema, name) {
|
||||
if (schema[name] === undefined) return;
|
||||
if (typeof schema[name] !== "number" || !Number.isFinite(schema[name])) {
|
||||
throw new TypeError(`Restricted setting schema ${name} must be finite`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRestrictedJsonSchema(root, options = {}) {
|
||||
const rootType = options.rootType ?? null;
|
||||
const stack = [{ schema: root, depth: 0, label: "valueSchema" }];
|
||||
let nodes = 0;
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (!current || !isPlainRecord(current.schema)) {
|
||||
throw new TypeError("Restricted setting schemas must contain plain objects");
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > MAX_SCHEMA_NODES || current.depth > MAX_SCHEMA_DEPTH) {
|
||||
throw new TypeError("Restricted setting schema is too complex");
|
||||
}
|
||||
const schema = current.schema;
|
||||
for (const keyword of Object.keys(schema)) {
|
||||
if (!ALLOWED_KEYWORDS.has(keyword)) {
|
||||
throw new TypeError(`Restricted setting schema keyword is not allowed: ${keyword}`);
|
||||
}
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(schema.type)) {
|
||||
throw new TypeError("Every restricted setting schema node must declare one supported type");
|
||||
}
|
||||
if (current.depth === 0 && rootType && schema.type !== rootType) {
|
||||
throw new TypeError(`Restricted setting schema root must use type ${rootType}`);
|
||||
}
|
||||
if (schema.enum !== undefined) {
|
||||
if (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > MAX_ENUM_ITEMS) {
|
||||
throw new TypeError("Restricted setting schema enum is invalid");
|
||||
}
|
||||
}
|
||||
assertIntegerKeyword(schema, "minItems");
|
||||
assertIntegerKeyword(schema, "maxItems");
|
||||
assertIntegerKeyword(schema, "minLength");
|
||||
assertIntegerKeyword(schema, "maxLength");
|
||||
assertNumberKeyword(schema, "minimum");
|
||||
assertNumberKeyword(schema, "maximum");
|
||||
if (schema.minItems !== undefined && schema.maxItems !== undefined && schema.minItems > schema.maxItems) {
|
||||
throw new TypeError("Restricted setting schema minItems exceeds maxItems");
|
||||
}
|
||||
if (schema.minLength !== undefined && schema.maxLength !== undefined && schema.minLength > schema.maxLength) {
|
||||
throw new TypeError("Restricted setting schema minLength exceeds maxLength");
|
||||
}
|
||||
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum) {
|
||||
throw new TypeError("Restricted setting schema minimum exceeds maximum");
|
||||
}
|
||||
if (schema.type === "integer" && schema.minimum !== undefined && schema.maximum !== undefined
|
||||
&& Math.ceil(schema.minimum) > Math.floor(schema.maximum)) {
|
||||
throw new TypeError("Restricted integer setting schema has no valid value");
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!isPlainRecord(schema.items)) throw new TypeError("Restricted array schemas require one items schema");
|
||||
stack.push({ schema: schema.items, depth: current.depth + 1, label: `${current.label}.items` });
|
||||
} else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) {
|
||||
throw new TypeError("Restricted setting schema array keywords require type array");
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
if (!isPlainRecord(schema.properties)) throw new TypeError("Restricted object schemas require properties");
|
||||
if (schema.additionalProperties !== false) {
|
||||
throw new TypeError("Restricted object schemas must set additionalProperties to false");
|
||||
}
|
||||
const propertyNames = Object.keys(schema.properties);
|
||||
for (const name of propertyNames) {
|
||||
assertSafeName(name, "Restricted setting schema property name");
|
||||
stack.push({ schema: schema.properties[name], depth: current.depth + 1, label: `${current.label}.${name}` });
|
||||
}
|
||||
if (schema.required !== undefined) {
|
||||
if (!Array.isArray(schema.required) || new Set(schema.required).size !== schema.required.length
|
||||
|| schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties, name))) {
|
||||
throw new TypeError("Restricted object schema required fields are invalid");
|
||||
}
|
||||
}
|
||||
} else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) {
|
||||
throw new TypeError("Restricted setting schema object keywords require type object");
|
||||
}
|
||||
if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) {
|
||||
throw new TypeError("Restricted setting schema string keywords require type string");
|
||||
}
|
||||
if (!["integer", "number"].includes(schema.type) && (schema.minimum !== undefined || schema.maximum !== undefined)) {
|
||||
throw new TypeError("Restricted setting schema numeric keywords require type number or integer");
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function compileRestrictedJsonSchema(schema, options = {}) {
|
||||
assertRestrictedJsonSchema(schema, options);
|
||||
let validate;
|
||||
try { validate = ajv.compile(schema); }
|
||||
catch (error) { throw new TypeError(`Restricted setting schema is invalid: ${error?.message ?? error}`); }
|
||||
return (value) => {
|
||||
if (validate(value)) return true;
|
||||
const details = ajv.errorsText(validate.errors, { separator: "; " });
|
||||
throw new TypeError(`Value does not match its restricted setting schema: ${details}`);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_KEYWORDS,
|
||||
MAX_SCHEMA_DEPTH,
|
||||
MAX_SCHEMA_NODES,
|
||||
assertRestrictedJsonSchema,
|
||||
compileRestrictedJsonSchema,
|
||||
};
|
||||
55
electron/plugins/restrictedJsonSchema.test.cjs
Normal file
55
electron/plugins/restrictedJsonSchema.test.cjs
Normal file
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
assertRestrictedJsonSchema,
|
||||
compileRestrictedJsonSchema,
|
||||
} = require("./restrictedJsonSchema.cjs");
|
||||
|
||||
function tableSchema() {
|
||||
return {
|
||||
type: "array",
|
||||
maxItems: 8,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 32 },
|
||||
port: { type: "integer", minimum: 1, maximum: 65_535 },
|
||||
},
|
||||
required: ["name", "port"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("restricted setting schemas validate bounded structured values", () => {
|
||||
const validate = compileRestrictedJsonSchema(tableSchema(), { rootType: "array" });
|
||||
assert.equal(validate([{ name: "primary", port: 22 }]), true);
|
||||
assert.throws(() => validate([{ name: "primary", port: 70_000 }]), /does not match/u);
|
||||
assert.throws(() => validate([{ name: "primary", port: 22, token: "secret" }]), /does not match/u);
|
||||
});
|
||||
|
||||
test("restricted setting schemas reject executable or ambiguous JSON Schema features", () => {
|
||||
assert.throws(() => assertRestrictedJsonSchema({
|
||||
type: "array",
|
||||
items: { type: "string", pattern: "(a+)+$" },
|
||||
}, { rootType: "array" }), /keyword is not allowed: pattern/u);
|
||||
assert.throws(() => assertRestrictedJsonSchema({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { constructor: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
}), /property name is invalid/u);
|
||||
assert.throws(() => assertRestrictedJsonSchema({
|
||||
type: "object",
|
||||
properties: {},
|
||||
}), /additionalProperties/u);
|
||||
assert.throws(() => assertRestrictedJsonSchema({
|
||||
type: "array",
|
||||
items: { type: "integer", minimum: 1.2, maximum: 1.8 },
|
||||
}, { rootType: "array" }), /no valid value/u);
|
||||
});
|
||||
437
electron/plugins/rpcRouter.cjs
Normal file
437
electron/plugins/rpcRouter.cjs
Normal file
@@ -0,0 +1,437 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const {
|
||||
PLUGIN_RPC_DEFAULT_TIMEOUT_MS,
|
||||
PLUGIN_RPC_MAX_PENDING,
|
||||
} = require("./constants.cjs");
|
||||
const {
|
||||
assertInitializeResult,
|
||||
assertRpcMessage,
|
||||
assertStreamFrameSchema,
|
||||
} = require("./contractValidator.cjs");
|
||||
const { PluginStreamRouter, assertStreamEnvelopeShape } = require("./streamRouter.cjs");
|
||||
|
||||
const RPC_ERRORS = Object.freeze({
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internal: -32603,
|
||||
cancelled: -32001,
|
||||
invalidArgument: -32003,
|
||||
deadlineExceeded: -32004,
|
||||
notFound: -32005,
|
||||
alreadyExists: -32006,
|
||||
permissionDenied: -32007,
|
||||
resourceExhausted: -32008,
|
||||
failedPrecondition: -32009,
|
||||
aborted: -32010,
|
||||
outOfRange: -32011,
|
||||
unsupported: -32012,
|
||||
unavailable: -32014,
|
||||
dataLoss: -32015,
|
||||
unauthenticated: -32016,
|
||||
});
|
||||
|
||||
class PluginRpcError extends Error {
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.name = "PluginRpcError";
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
function rpcIdKey(id) {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function toRpcError(error) {
|
||||
if (error instanceof PluginRpcError) {
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message.slice(0, 2_048) || "Plugin RPC failed",
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
};
|
||||
}
|
||||
return { code: RPC_ERRORS.internal, message: "Plugin host request failed" };
|
||||
}
|
||||
|
||||
function raceWithAbort(operation, signal) {
|
||||
if (signal.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class PluginRpcRouter {
|
||||
constructor(options) {
|
||||
this.pluginId = options.pluginId;
|
||||
this.send = options.send;
|
||||
const legacyHandlers = options.handlers ?? {};
|
||||
this.requestHandlers = new Map(Object.entries(options.requestHandlers ?? legacyHandlers));
|
||||
this.notificationHandlers = new Map(Object.entries(options.notificationHandlers ?? legacyHandlers));
|
||||
this.pending = new Map();
|
||||
this.pendingCancellationIds = new Set();
|
||||
this.retiredResponseIds = new Set();
|
||||
this.inflight = new Map();
|
||||
this.inflightIds = new Set();
|
||||
this.inflightNotifications = new Set();
|
||||
this.nextId = 0;
|
||||
this.closed = false;
|
||||
this.maxPending = options.maxPending ?? PLUGIN_RPC_MAX_PENDING;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? PLUGIN_RPC_DEFAULT_TIMEOUT_MS;
|
||||
this.onProtocolError = options.onProtocolError ?? (() => {});
|
||||
this.onBeforeMessage = options.onBeforeMessage ?? (() => {});
|
||||
if (typeof this.onBeforeMessage !== "function") {
|
||||
throw new TypeError("Plugin RPC message guard must be a function");
|
||||
}
|
||||
this.progress = options.onProgress ?? (() => {});
|
||||
this.streams = new PluginStreamRouter({
|
||||
send: (message, transfer) => this.send(message, transfer),
|
||||
onIncomingStream: options.onIncomingStream,
|
||||
maxStreams: this.maxPending,
|
||||
openTimeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
this.streamChains = new Map();
|
||||
}
|
||||
|
||||
#sendRpc(message, transfer = []) {
|
||||
assertRpcMessage(message);
|
||||
if (!Array.isArray(transfer)) {
|
||||
throw new TypeError("Plugin RPC transfer list must be an array");
|
||||
}
|
||||
this.send(message, transfer);
|
||||
}
|
||||
|
||||
#sendFailure(id, error) {
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", id, error: toRpcError(error) });
|
||||
} catch {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: RPC_ERRORS.internal, message: "Plugin host request failed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#allocateRequestId() {
|
||||
for (let attempt = 0; attempt <= this.maxPending + this.retiredResponseIds.size; attempt += 1) {
|
||||
const id = this.nextId;
|
||||
this.nextId = this.nextId === Number.MAX_SAFE_INTEGER ? 0 : this.nextId + 1;
|
||||
const key = rpcIdKey(id);
|
||||
if (!this.pending.has(key) && !this.retiredResponseIds.has(key)) return id;
|
||||
}
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "No RPC correlation ID is available");
|
||||
}
|
||||
|
||||
#allocateCancellationId() {
|
||||
let cancellationId;
|
||||
do cancellationId = `host-${randomUUID()}`;
|
||||
while (this.pendingCancellationIds.has(cancellationId));
|
||||
return cancellationId;
|
||||
}
|
||||
|
||||
#forgetPending(id, pending) {
|
||||
this.pending.delete(rpcIdKey(id));
|
||||
this.pendingCancellationIds.delete(pending.cancellationId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.abortCleanup?.();
|
||||
}
|
||||
|
||||
#retireResponseId(id) {
|
||||
const key = rpcIdKey(id);
|
||||
this.retiredResponseIds.delete(key);
|
||||
this.retiredResponseIds.add(key);
|
||||
while (this.retiredResponseIds.size > this.maxPending) {
|
||||
this.retiredResponseIds.delete(this.retiredResponseIds.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
accept(rawMessage) {
|
||||
return this.#accept(rawMessage)
|
||||
.catch((error) => {
|
||||
if (this.closed) return;
|
||||
this.onProtocolError(error);
|
||||
this.close(error);
|
||||
});
|
||||
}
|
||||
|
||||
async #accept(rawMessage) {
|
||||
if (this.closed) return;
|
||||
const guardResult = this.onBeforeMessage(rawMessage);
|
||||
if (guardResult && typeof guardResult.then === "function") {
|
||||
void Promise.resolve(guardResult).catch(() => {});
|
||||
throw new TypeError("Plugin RPC message guard must be synchronous");
|
||||
}
|
||||
const message = rawMessage;
|
||||
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
|
||||
assertStreamEnvelopeShape(message);
|
||||
assertStreamFrameSchema(message.frame);
|
||||
const streamId = message.frame.streamId;
|
||||
const previous = this.streamChains.get(streamId) ?? Promise.resolve();
|
||||
const current = previous.then(() => this.streams.accept(message));
|
||||
this.streamChains.set(streamId, current);
|
||||
try {
|
||||
await current;
|
||||
} finally {
|
||||
if (this.streamChains.get(streamId) === current) this.streamChains.delete(streamId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertRpcMessage(message);
|
||||
if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
|
||||
this.#acceptResponse(message);
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/progress") {
|
||||
this.progress(message.params);
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/cancelRequest") {
|
||||
this.#cancelInflight(message.params.cancellationId);
|
||||
return;
|
||||
}
|
||||
if (Object.hasOwn(message, "id")) {
|
||||
await this.#acceptRequest(message);
|
||||
return;
|
||||
}
|
||||
await this.#acceptNotification(message);
|
||||
}
|
||||
|
||||
#acceptResponse(message) {
|
||||
const key = rpcIdKey(message.id);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) {
|
||||
if (this.retiredResponseIds.delete(key)) return;
|
||||
throw new Error(`Plugin returned an unknown RPC response ID: ${String(message.id)}`);
|
||||
}
|
||||
this.#forgetPending(message.id, pending);
|
||||
if (Object.hasOwn(message, "error")) {
|
||||
pending.reject(new PluginRpcError(message.error.code, message.error.message, message.error.data));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let result = pending.method === "plugin.initialize"
|
||||
? assertInitializeResult(message.result)
|
||||
: message.result;
|
||||
if (pending.validateResult) result = pending.validateResult(result);
|
||||
pending.resolve(result);
|
||||
} catch (error) {
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #acceptRequest(message) {
|
||||
if (this.inflight.size >= this.maxPending) {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: RPC_ERRORS.resourceExhausted, message: "Too many in-flight plugin requests" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handler = this.requestHandlers.get(message.method);
|
||||
if (!handler) {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: RPC_ERRORS.methodNotFound, message: `Unsupported plugin method: ${message.method}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const requestIdKey = rpcIdKey(message.id);
|
||||
if (this.inflightIds.has(requestIdKey)) {
|
||||
this.#sendFailure(message.id, new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Duplicate in-flight plugin request ID",
|
||||
));
|
||||
return;
|
||||
}
|
||||
let cancellationId = message.cancellationId;
|
||||
if (cancellationId && this.inflight.has(cancellationId)) {
|
||||
this.#sendFailure(message.id, new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Duplicate in-flight plugin cancellation ID",
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (!cancellationId) {
|
||||
do cancellationId = `host-${randomUUID()}`;
|
||||
while (this.inflight.has(cancellationId));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = message.deadlineMs ?? this.defaultTimeoutMs;
|
||||
const timer = setTimeout(() => controller.abort(new PluginRpcError(
|
||||
RPC_ERRORS.deadlineExceeded,
|
||||
"Plugin request deadline exceeded",
|
||||
)), timeoutMs);
|
||||
this.inflight.set(cancellationId, controller);
|
||||
this.inflightIds.add(requestIdKey);
|
||||
try {
|
||||
const result = await raceWithAbort(Promise.resolve(handler(message.params, {
|
||||
pluginId: this.pluginId,
|
||||
signal: controller.signal,
|
||||
cancellationId,
|
||||
deadlineMs: timeoutMs,
|
||||
requestId: message.id,
|
||||
})), controller.signal);
|
||||
if (!this.closed) this.#sendRpc({ jsonrpc: "2.0", id: message.id, result: result ?? null });
|
||||
} catch (error) {
|
||||
if (!this.closed) this.#sendFailure(message.id, error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflight.delete(cancellationId);
|
||||
this.inflightIds.delete(requestIdKey);
|
||||
}
|
||||
}
|
||||
|
||||
async #acceptNotification(message) {
|
||||
const handler = this.notificationHandlers.get(message.method);
|
||||
if (!handler) return;
|
||||
if (this.inflightNotifications.size >= this.maxPending) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many in-flight plugin notifications");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new PluginRpcError(
|
||||
RPC_ERRORS.deadlineExceeded,
|
||||
"Plugin notification deadline exceeded",
|
||||
)), this.defaultTimeoutMs);
|
||||
this.inflightNotifications.add(controller);
|
||||
try {
|
||||
await raceWithAbort(Promise.resolve(handler(message.params, {
|
||||
pluginId: this.pluginId,
|
||||
signal: controller.signal,
|
||||
notification: true,
|
||||
})), controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflightNotifications.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
#cancelInflight(cancellationId) {
|
||||
const controller = this.inflight.get(cancellationId);
|
||||
controller?.abort(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
}
|
||||
|
||||
request(method, params, options = {}) {
|
||||
if (this.closed) return Promise.reject(new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime is closed"));
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
}
|
||||
if (this.pending.size >= this.maxPending) {
|
||||
return Promise.reject(new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many pending plugin requests"));
|
||||
}
|
||||
const id = this.#allocateRequestId();
|
||||
const cancellationId = options.cancellationId ?? this.#allocateCancellationId();
|
||||
if (this.pendingCancellationIds.has(cancellationId)) {
|
||||
return Promise.reject(new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Plugin request cancellation ID is already in use",
|
||||
));
|
||||
}
|
||||
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
||||
if (options.validateResult != null && typeof options.validateResult !== "function") {
|
||||
return Promise.reject(new TypeError("Plugin RPC result validator must be a function"));
|
||||
}
|
||||
if (options.transfer != null && !Array.isArray(options.transfer)) {
|
||||
return Promise.reject(new TypeError("Plugin RPC transfer list must be an array"));
|
||||
}
|
||||
const message = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
deadlineMs: timeoutMs,
|
||||
cancellationId,
|
||||
};
|
||||
assertRpcMessage(message);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const pending = this.pending.get(rpcIdKey(id));
|
||||
if (pending) {
|
||||
this.#retireResponseId(id);
|
||||
this.#forgetPending(id, pending);
|
||||
}
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId } });
|
||||
} catch {}
|
||||
reject(new PluginRpcError(RPC_ERRORS.deadlineExceeded, `Plugin request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
let abortCleanup;
|
||||
if (options.signal) {
|
||||
const onAbort = () => {
|
||||
const pending = this.pending.get(rpcIdKey(id));
|
||||
if (pending) {
|
||||
this.#retireResponseId(id);
|
||||
this.#forgetPending(id, pending);
|
||||
}
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId } });
|
||||
} catch {}
|
||||
reject(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
};
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
const pending = {
|
||||
method,
|
||||
cancellationId,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
abortCleanup,
|
||||
validateResult: options.validateResult,
|
||||
};
|
||||
this.pending.set(rpcIdKey(id), pending);
|
||||
this.pendingCancellationIds.add(cancellationId);
|
||||
try {
|
||||
this.#sendRpc(message, options.transfer ?? []);
|
||||
} catch (error) {
|
||||
this.#forgetPending(id, pending);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
if (this.closed) throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime is closed");
|
||||
const message = { jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) };
|
||||
this.#sendRpc(message);
|
||||
}
|
||||
|
||||
close(error = new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime closed")) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.abortCleanup?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.pendingCancellationIds.clear();
|
||||
this.retiredResponseIds.clear();
|
||||
for (const controller of this.inflight.values()) controller.abort(error);
|
||||
this.inflight.clear();
|
||||
this.inflightIds.clear();
|
||||
for (const controller of this.inflightNotifications) controller.abort(error);
|
||||
this.inflightNotifications.clear();
|
||||
this.streams.close(error);
|
||||
this.streamChains.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginRpcError, PluginRpcRouter, RPC_ERRORS, raceWithAbort, rpcIdKey };
|
||||
427
electron/plugins/rpcRouter.test.cjs
Normal file
427
electron/plugins/rpcRouter.test.cjs
Normal file
@@ -0,0 +1,427 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginRpcError, PluginRpcRouter, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { PLUGIN_RPC_MAX_JSON_BYTES } = require("./constants.cjs");
|
||||
|
||||
function createRouter(options = {}) {
|
||||
const sent = [];
|
||||
const transfers = [];
|
||||
const protocolErrors = [];
|
||||
const router = new PluginRpcRouter({
|
||||
pluginId: "com.example.test",
|
||||
send(message, transfer = []) {
|
||||
options.send?.(message, transfer);
|
||||
sent.push(message);
|
||||
transfers.push(transfer);
|
||||
},
|
||||
handlers: options.handlers,
|
||||
requestHandlers: options.requestHandlers,
|
||||
notificationHandlers: options.notificationHandlers,
|
||||
maxPending: options.maxPending,
|
||||
defaultTimeoutMs: options.defaultTimeoutMs ?? 100,
|
||||
onBeforeMessage: options.onBeforeMessage,
|
||||
onIncomingStream: options.onIncomingStream,
|
||||
onProtocolError(error) { protocolErrors.push(error); },
|
||||
});
|
||||
return { router, sent, transfers, protocolErrors };
|
||||
}
|
||||
|
||||
test("raw message guards run before schema work and can contain every transport message class", async () => {
|
||||
const messages = [
|
||||
{ malformed: true },
|
||||
{ jsonrpc: "2.0", id: 1, method: "storage.get", params: { key: "x" } },
|
||||
{ jsonrpc: "2.0", method: "log.write", params: {} },
|
||||
{ jsonrpc: "2.0", id: 2, result: null },
|
||||
{ jsonrpc: "2.0", method: "$/progress", params: { token: "x", value: null } },
|
||||
{ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId: "x" } },
|
||||
{ frame: { streamId: "x", sequence: 0, kind: "open", windowBytes: 1 } },
|
||||
];
|
||||
for (const guarded of messages) {
|
||||
let seen;
|
||||
const fixture = createRouter({
|
||||
onBeforeMessage(message) {
|
||||
seen = message;
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "runtime message quota exceeded");
|
||||
},
|
||||
});
|
||||
|
||||
await fixture.router.accept(guarded);
|
||||
assert.equal(seen, guarded);
|
||||
assert.equal(fixture.router.closed, true);
|
||||
assert.equal(fixture.protocolErrors[0].code, RPC_ERRORS.resourceExhausted);
|
||||
}
|
||||
});
|
||||
|
||||
test("raw message guards must remain synchronous", async () => {
|
||||
const fixture = createRouter({ onBeforeMessage: async () => true });
|
||||
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
|
||||
assert.equal(fixture.router.closed, true);
|
||||
assert.match(fixture.protocolErrors[0].message, /must be synchronous/);
|
||||
});
|
||||
|
||||
test("RPC correlation validates initialize results against the reserved contract", async () => {
|
||||
const fixture = createRouter();
|
||||
const resultPromise = fixture.router.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
const request = fixture.sent[0];
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
result: {
|
||||
pluginId: "com.example.test",
|
||||
pluginVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
});
|
||||
assert.equal((await resultPromise).pluginId, "com.example.test");
|
||||
|
||||
const invalidPromise = fixture.router.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: fixture.sent.at(-1).id, result: {} });
|
||||
await assert.rejects(invalidPromise, /Initialize result violates/);
|
||||
});
|
||||
|
||||
test("RPC deadline sends cancellation and rejects without leaking pending state", async () => {
|
||||
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||||
const request = fixture.router.request("plugin.activate", {});
|
||||
await assert.rejects(request, (error) => error instanceof PluginRpcError && error.code === RPC_ERRORS.deadlineExceeded);
|
||||
assert.equal(fixture.sent.at(-1).method, "$/cancelRequest");
|
||||
assert.equal(fixture.router.pending.size, 0);
|
||||
});
|
||||
|
||||
test("one late response after cancellation is ignored without hiding duplicates", async () => {
|
||||
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||||
const request = fixture.router.request("provider.invoke", {});
|
||||
const requestId = fixture.sent[0].id;
|
||||
await assert.rejects(request, (error) => error?.code === RPC_ERRORS.deadlineExceeded);
|
||||
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { late: true } });
|
||||
assert.equal(fixture.router.closed, false);
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { duplicate: true } });
|
||||
assert.equal(fixture.router.closed, true);
|
||||
assert.equal(fixture.protocolErrors.length, 1);
|
||||
});
|
||||
|
||||
test("retired response IDs are not reused for new requests", async () => {
|
||||
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||||
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
|
||||
const expired = fixture.router.request("provider.expired", {});
|
||||
assert.equal(fixture.sent[0].id, Number.MAX_SAFE_INTEGER);
|
||||
await assert.rejects(expired);
|
||||
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
|
||||
const next = fixture.router.request("provider.next", {});
|
||||
assert.equal(fixture.sent.at(-1).id, 0);
|
||||
fixture.router.close();
|
||||
await assert.rejects(next);
|
||||
});
|
||||
|
||||
test("incoming request deadlines do not block later requests", async () => {
|
||||
const fixture = createRouter({
|
||||
defaultTimeoutMs: 20,
|
||||
handlers: {
|
||||
"storage.get": async () => new Promise(() => {}),
|
||||
"storage.keys": async () => ({ keys: ["ready"] }),
|
||||
},
|
||||
});
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "slow",
|
||||
method: "storage.get",
|
||||
params: { key: "blocked" },
|
||||
deadlineMs: 10,
|
||||
});
|
||||
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.deadlineExceeded);
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "next",
|
||||
method: "storage.keys",
|
||||
params: {},
|
||||
});
|
||||
assert.deepEqual(fixture.sent[1].result, { keys: ["ready"] });
|
||||
});
|
||||
|
||||
test("a stalled stream consumer does not block frames for another stream", async () => {
|
||||
let releaseSlowChunk;
|
||||
const slowChunk = new Promise((resolve) => { releaseSlowChunk = resolve; });
|
||||
const fixture = createRouter({
|
||||
onIncomingStream({ streamId, bind }) {
|
||||
bind({
|
||||
async onChunk(_chunk, release) {
|
||||
if (streamId === "slow") await slowChunk;
|
||||
release();
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
await fixture.router.accept({
|
||||
frame: { streamId: "slow", sequence: 0, kind: "open", windowBytes: 1024 },
|
||||
});
|
||||
const pendingSlowChunk = fixture.router.accept({
|
||||
frame: {
|
||||
streamId: "slow",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: contract.createJsonStreamChunk({ value: "slow" }),
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
await fixture.router.accept({
|
||||
frame: { streamId: "independent", sequence: 0, kind: "open", windowBytes: 1024 },
|
||||
});
|
||||
assert.equal(fixture.router.streams.incoming.has("independent"), true);
|
||||
|
||||
releaseSlowChunk();
|
||||
await pendingSlowChunk;
|
||||
});
|
||||
|
||||
test("incoming handlers can await a nested outgoing RPC response", async () => {
|
||||
let fixture;
|
||||
fixture = createRouter({
|
||||
handlers: {
|
||||
"storage.get": async () => fixture.router.request("plugin.nested", {}),
|
||||
},
|
||||
});
|
||||
const incoming = fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "incoming",
|
||||
method: "storage.get",
|
||||
params: { key: "value" },
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const nested = fixture.sent[0];
|
||||
assert.equal(nested.method, "plugin.nested");
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: nested.id, result: { value: 42 } });
|
||||
await incoming;
|
||||
assert.deepEqual(fixture.sent[1], {
|
||||
jsonrpc: "2.0",
|
||||
id: "incoming",
|
||||
result: { value: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
test("an already-aborted outgoing request is never sent", async () => {
|
||||
const fixture = createRouter();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fixture.router.request("plugin.activate", {}, { signal: controller.signal }),
|
||||
(error) => error?.code === RPC_ERRORS.cancelled,
|
||||
);
|
||||
assert.deepEqual(fixture.sent, []);
|
||||
});
|
||||
|
||||
test("synchronous transport failure releases RPC correlation and cancellation ownership", async () => {
|
||||
const fixture = createRouter({ send() { throw new Error("port is closed"); } });
|
||||
await assert.rejects(fixture.router.request("plugin.activate", {}), /port is closed/);
|
||||
assert.equal(fixture.router.pending.size, 0);
|
||||
assert.equal(fixture.router.pendingCancellationIds.size, 0);
|
||||
});
|
||||
|
||||
test("outgoing cancellation IDs cannot alias concurrent requests", async () => {
|
||||
const fixture = createRouter();
|
||||
const first = fixture.router.request("plugin.activate", {}, { cancellationId: "same" });
|
||||
await assert.rejects(
|
||||
fixture.router.request("plugin.deactivate", {}, { cancellationId: "same" }),
|
||||
(error) => error?.code === RPC_ERRORS.invalidParams,
|
||||
);
|
||||
fixture.router.close();
|
||||
await assert.rejects(first, (error) => error?.code === RPC_ERRORS.unavailable);
|
||||
});
|
||||
|
||||
test("duplicate in-flight request and cancellation IDs fail without replacing ownership", async () => {
|
||||
let release;
|
||||
const blocked = new Promise((resolve) => { release = resolve; });
|
||||
const fixture = createRouter({
|
||||
handlers: { "storage.keys": async () => blocked },
|
||||
});
|
||||
const first = fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "same-id",
|
||||
method: "storage.keys",
|
||||
params: {},
|
||||
cancellationId: "same-cancel",
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "same-id",
|
||||
method: "storage.keys",
|
||||
params: {},
|
||||
cancellationId: "different-cancel",
|
||||
});
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "different-id",
|
||||
method: "storage.keys",
|
||||
params: {},
|
||||
cancellationId: "same-cancel",
|
||||
});
|
||||
assert.deepEqual(fixture.sent.map((message) => message.error?.code), [
|
||||
RPC_ERRORS.invalidParams,
|
||||
RPC_ERRORS.invalidParams,
|
||||
]);
|
||||
release({ keys: [] });
|
||||
await first;
|
||||
assert.deepEqual(fixture.sent.at(-1), {
|
||||
jsonrpc: "2.0",
|
||||
id: "same-id",
|
||||
result: { keys: [] },
|
||||
});
|
||||
assert.equal(fixture.router.closed, false);
|
||||
});
|
||||
|
||||
test("invalid host handler results become bounded internal errors", async () => {
|
||||
const fixture = createRouter({
|
||||
handlers: { "storage.keys": async () => ({ unsafe: 1n }) },
|
||||
});
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "invalid-result",
|
||||
method: "storage.keys",
|
||||
params: {},
|
||||
});
|
||||
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.internal);
|
||||
assert.equal(fixture.router.closed, false);
|
||||
});
|
||||
|
||||
test("incoming requests retain host-assigned plugin identity and unsupported methods fail immediately", async () => {
|
||||
const identities = [];
|
||||
const fixture = createRouter({
|
||||
handlers: {
|
||||
"storage.keys": async (_params, context) => {
|
||||
identities.push(context.pluginId);
|
||||
return { keys: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: "one", method: "storage.keys", params: {} });
|
||||
assert.deepEqual(identities, ["com.example.test"]);
|
||||
assert.deepEqual(fixture.sent[0], { jsonrpc: "2.0", id: "one", result: { keys: [] } });
|
||||
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: "two", method: "host.unsupported", params: {} });
|
||||
assert.equal(fixture.sent[1].error.code, RPC_ERRORS.methodNotFound);
|
||||
});
|
||||
|
||||
test("request and notification handler ownership cannot be confused", async () => {
|
||||
const calls = [];
|
||||
const fixture = createRouter({
|
||||
requestHandlers: { "storage.set": async () => { calls.push("request"); return null; } },
|
||||
notificationHandlers: { "log.write": async () => { calls.push("notification"); } },
|
||||
});
|
||||
await fixture.router.accept({ jsonrpc: "2.0", method: "storage.set", params: { key: "x", value: 1 } });
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: "log", method: "log.write", params: {} });
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.methodNotFound);
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: "write", method: "storage.set", params: {} });
|
||||
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
|
||||
assert.deepEqual(calls, ["request", "notification"]);
|
||||
});
|
||||
|
||||
test("closing the router aborts in-flight notification handlers", async () => {
|
||||
let observedSignal;
|
||||
const fixture = createRouter({
|
||||
notificationHandlers: {
|
||||
"events.consume": async (_params, context) => {
|
||||
observedSignal = context.signal;
|
||||
await new Promise((resolve, reject) => {
|
||||
context.signal.addEventListener("abort", () => reject(context.signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const accepting = fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
method: "events.consume",
|
||||
params: {},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
fixture.router.close();
|
||||
await accepting;
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
assert.equal(fixture.router.inflightNotifications.size, 0);
|
||||
});
|
||||
|
||||
test("outgoing requests validate method-specific plugin results before resolving", async () => {
|
||||
const fixture = createRouter();
|
||||
const response = fixture.router.request("provider.invoke", {}, {
|
||||
validateResult(result) {
|
||||
if (result?.status !== "ok") throw new TypeError("Invalid provider result");
|
||||
return Object.freeze({ ...result });
|
||||
},
|
||||
});
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: fixture.sent[0].id,
|
||||
result: { status: "failed" },
|
||||
});
|
||||
await assert.rejects(response, /Invalid provider result/);
|
||||
await assert.rejects(
|
||||
fixture.router.request("provider.invoke", {}, { validateResult: true }),
|
||||
/validator must be a function/,
|
||||
);
|
||||
});
|
||||
|
||||
test("outgoing requests keep transferable setup under router correlation and validation", async () => {
|
||||
const fixture = createRouter();
|
||||
const port = { postMessage() {} };
|
||||
const response = fixture.router.request("plugin.terminal.interceptor.attach", {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
}, {
|
||||
transfer: [port],
|
||||
validateResult(result) {
|
||||
if (result?.accepted !== true) throw new TypeError("Invalid attachment result");
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(fixture.transfers[0], [port]);
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: fixture.sent[0].id,
|
||||
result: { accepted: true },
|
||||
});
|
||||
assert.equal(await response, undefined);
|
||||
await assert.rejects(
|
||||
fixture.router.request("plugin.terminal.interceptor.attach", {}, { transfer: port }),
|
||||
/transfer list must be an array/,
|
||||
);
|
||||
});
|
||||
|
||||
test("malformed RPC closes the peer instead of accepting a near-match", async () => {
|
||||
const fixture = createRouter();
|
||||
await fixture.router.accept({ jsonrpc: "2.0", id: -1, method: "plugin.activate" });
|
||||
assert.equal(fixture.protocolErrors.length, 1);
|
||||
assert.equal(fixture.router.closed, true);
|
||||
});
|
||||
|
||||
test("oversized control messages close the peer before dispatch", async () => {
|
||||
let calls = 0;
|
||||
const fixture = createRouter({
|
||||
handlers: { "storage.set": () => { calls += 1; } },
|
||||
});
|
||||
await fixture.router.accept({
|
||||
jsonrpc: "2.0",
|
||||
id: "oversized",
|
||||
method: "storage.set",
|
||||
params: { key: "value", value: "x".repeat(PLUGIN_RPC_MAX_JSON_BYTES) },
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(fixture.router.closed, true);
|
||||
assert.match(fixture.protocolErrors[0].message, /exceeds/);
|
||||
});
|
||||
32
electron/plugins/runtime/browserPreload.cjs
Normal file
32
electron/plugins/runtime/browserPreload.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
let hostPort;
|
||||
let runtimeReady = false;
|
||||
|
||||
function connectRuntime() {
|
||||
if (!hostPort || !runtimeReady) return;
|
||||
const port = hostPort;
|
||||
hostPort = undefined;
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:connect",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*", [port]);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.runtimeToken !== window.location.hostname) return;
|
||||
if (event.data?.type === "netcatty-plugin:runtime-ready") {
|
||||
runtimeReady = true;
|
||||
connectRuntime();
|
||||
} else if (event.data?.type === "netcatty-plugin:runtime-connected") {
|
||||
ipcRenderer.send("netcatty-plugin:runtime-connected");
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.once("netcatty-plugin:connect", (event) => {
|
||||
hostPort = event.ports?.[0];
|
||||
connectRuntime();
|
||||
});
|
||||
ipcRenderer.send("netcatty-plugin:preload-ready");
|
||||
74
electron/plugins/runtime/browserRuntime.mjs
Normal file
74
electron/plugins/runtime/browserRuntime.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { startPluginRuntime } from "./runtimePeer.mjs";
|
||||
|
||||
function disableDirectBrowserCapability(name) {
|
||||
try {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: undefined,
|
||||
writable: false,
|
||||
});
|
||||
} catch {
|
||||
try { globalThis[name] = undefined; } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function lockDirectBrowserCapabilities() {
|
||||
for (const name of [
|
||||
"EventSource",
|
||||
"RTCPeerConnection",
|
||||
"SharedWorker",
|
||||
"WebSocket",
|
||||
"WebSocketStream",
|
||||
"WebTransport",
|
||||
"Worker",
|
||||
"XMLHttpRequest",
|
||||
"fetch",
|
||||
"webkitRTCPeerConnection",
|
||||
]) disableDirectBrowserCapability(name);
|
||||
try {
|
||||
Object.defineProperty(Navigator.prototype, "sendBeacon", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: () => false,
|
||||
writable: false,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function waitForPort() {
|
||||
return new Promise((resolve) => {
|
||||
const acceptPort = (event) => {
|
||||
if (
|
||||
event.data?.type !== "netcatty-plugin:connect"
|
||||
|| event.data?.runtimeToken !== window.location.hostname
|
||||
) return;
|
||||
const port = event.ports?.[0];
|
||||
if (!port) return;
|
||||
window.removeEventListener("message", acceptPort);
|
||||
resolve(port);
|
||||
};
|
||||
window.addEventListener("message", acceptPort);
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:runtime-ready",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*");
|
||||
});
|
||||
}
|
||||
|
||||
const [configResponse, port] = await Promise.all([
|
||||
fetch(new URL("./config.json", import.meta.url), { cache: "no-store", credentials: "omit" }),
|
||||
waitForPort(),
|
||||
]);
|
||||
if (!configResponse.ok) throw new Error("Unable to load plugin runtime configuration");
|
||||
const config = await configResponse.json();
|
||||
lockDirectBrowserCapabilities();
|
||||
await startPluginRuntime({
|
||||
port,
|
||||
config,
|
||||
loadPlugin: (entryUrl) => import(entryUrl),
|
||||
});
|
||||
window.postMessage({
|
||||
type: "netcatty-plugin:runtime-connected",
|
||||
runtimeToken: window.location.hostname,
|
||||
}, "*");
|
||||
11
electron/plugins/runtime/pluginModuleLoader.mjs
Normal file
11
electron/plugins/runtime/pluginModuleLoader.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
let mappings = new Map();
|
||||
|
||||
export function initialize(data) {
|
||||
mappings = new Map(Object.entries(data?.mappings ?? {}));
|
||||
}
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
const mapped = mappings.get(specifier);
|
||||
if (mapped) return { url: mapped, shortCircuit: true };
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
createMessagePortStreamEnvelope,
|
||||
materializeStreamChunk,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
|
||||
function assertStreamId(streamId) {
|
||||
if (typeof streamId !== "string"
|
||||
|| streamId.length < 1
|
||||
|| streamId.length > PLUGIN_STREAM_MAX_ID_LENGTH
|
||||
|| streamId.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream ID is invalid");
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
|
||||
function assertWindowBytes(windowBytes) {
|
||||
if (!Number.isSafeInteger(windowBytes)
|
||||
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|
||||
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream window is invalid");
|
||||
}
|
||||
return windowBytes;
|
||||
}
|
||||
|
||||
function copyBytes(value) {
|
||||
let source;
|
||||
if (value instanceof Uint8Array) source = value;
|
||||
else if (value instanceof ArrayBuffer) source = new Uint8Array(value);
|
||||
else throw new PluginError("invalid_argument", "Plugin stream writes require Uint8Array or ArrayBuffer");
|
||||
const copy = new Uint8Array(source.byteLength);
|
||||
copy.set(source);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function closedError(streamId) {
|
||||
return new PluginError("unavailable", `Plugin stream is closed: ${streamId}`);
|
||||
}
|
||||
|
||||
export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
const maxStreams = options.maxStreams ?? 128;
|
||||
const incoming = new Map();
|
||||
const outgoing = new Map();
|
||||
const pendingIncoming = new Map();
|
||||
let closed = false;
|
||||
|
||||
const sendEnvelope = (frame, transfer) => {
|
||||
const envelope = createMessagePortStreamEnvelope(frame, transfer);
|
||||
transport.post(envelope, transfer ? [transfer] : []);
|
||||
};
|
||||
|
||||
const activeStreams = () => incoming.size + outgoing.size;
|
||||
const reservedStreams = () => activeStreams() + pendingIncoming.size;
|
||||
|
||||
function releaseCurrent(state) {
|
||||
if (!state.currentCreditBytes || closed) return;
|
||||
const creditBytes = state.currentCreditBytes;
|
||||
state.currentCreditBytes = 0;
|
||||
state.availableBytes += creditBytes;
|
||||
state.updateSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.updateSequence,
|
||||
kind: "windowUpdate",
|
||||
creditBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function settleIncomingWaiters(state) {
|
||||
while (state.readers.length > 0) {
|
||||
if (state.queue.length > 0) {
|
||||
const reader = state.readers.shift();
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
reader.resolve(chunk.data);
|
||||
continue;
|
||||
}
|
||||
if (!state.closed) break;
|
||||
const reader = state.readers.shift();
|
||||
if (state.error) reader.reject(state.error);
|
||||
else reader.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
function closeIncoming(state, error, notify = false) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.error = error ?? null;
|
||||
incoming.delete(state.streamId);
|
||||
if (notify) {
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "cancel",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
settleIncomingWaiters(state);
|
||||
}
|
||||
|
||||
function readableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
async read() {
|
||||
if (state.readers.length > 0) {
|
||||
throw new PluginError("failed_precondition", "Plugin stream does not allow concurrent reads");
|
||||
}
|
||||
if (state.currentCreditBytes) releaseCurrent(state);
|
||||
if (state.queue.length > 0) {
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
return chunk.data;
|
||||
}
|
||||
if (state.closed) {
|
||||
if (state.error) throw state.error;
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve, reject) => state.readers.push({ resolve, reject }));
|
||||
},
|
||||
cancel() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was cancelled"), true); },
|
||||
dispose() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was disposed"), true); },
|
||||
});
|
||||
}
|
||||
|
||||
function flushOutgoing(state) {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const pending = state.queue[0];
|
||||
if (pending.bytes.byteLength > state.availableBytes) break;
|
||||
state.queue.shift();
|
||||
state.queuedBytes -= pending.bytes.byteLength;
|
||||
state.availableBytes -= pending.bytes.byteLength;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: pending.bytes.byteLength },
|
||||
}, pending.bytes.buffer);
|
||||
pending.resolve();
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
pending.reject(error);
|
||||
for (const queued of state.queue.splice(0)) queued.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
if (!state.closed && state.terminal === "end" && state.queue.length === 0 && !state.terminalSent) {
|
||||
state.terminalSent = true;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({ streamId: state.streamId, sequence: state.nextSequence, kind: "end" });
|
||||
state.endResolve?.();
|
||||
if (state.availableBytes === state.windowBytes) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
write(value) {
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
const bytes = copyBytes(value);
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
||||
return Promise.reject(new PluginError("out_of_range", "Plugin stream chunk size is invalid"));
|
||||
}
|
||||
if (state.queuedBytes + bytes.byteLength > state.windowBytes) {
|
||||
return Promise.reject(new PluginError("resource_exhausted", "Plugin stream pending writes exceed its window"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
state.queue.push({ bytes, resolve, reject });
|
||||
state.queuedBytes += bytes.byteLength;
|
||||
flushOutgoing(state);
|
||||
});
|
||||
},
|
||||
end() {
|
||||
if (state.terminal === "end") return state.endPromise;
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
state.terminal = "end";
|
||||
state.endPromise = new Promise((resolve, reject) => {
|
||||
state.endResolve = resolve;
|
||||
state.endReject = reject;
|
||||
});
|
||||
flushOutgoing(state);
|
||||
return state.endPromise;
|
||||
},
|
||||
fail(error) {
|
||||
if (state.closed || state.terminal) return;
|
||||
state.terminal = "error";
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const failure = new PluginError("data_loss", String(error?.message ?? "Plugin stream failed"));
|
||||
for (const pending of state.queue.splice(0)) pending.reject(failure);
|
||||
state.queuedBytes = 0;
|
||||
state.nextSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "error",
|
||||
error: {
|
||||
code: -32013,
|
||||
message: String(error?.message ?? "Plugin stream failed").slice(0, 2048),
|
||||
},
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const error = new PluginError("cancelled", "Plugin stream was cancelled");
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence + 1,
|
||||
kind: "cancel",
|
||||
});
|
||||
},
|
||||
dispose() { this.cancel(); },
|
||||
});
|
||||
}
|
||||
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "frame")) return false;
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
const frame = envelope.frame;
|
||||
if (frame.kind === "open") {
|
||||
if (!pendingIncoming.has(frame.streamId)) return false;
|
||||
if (closed || activeStreams() >= maxStreams || incoming.has(frame.streamId) || outgoing.has(frame.streamId)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${frame.streamId}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: frame.streamId,
|
||||
availableBytes: frame.windowBytes,
|
||||
nextSequence: 1,
|
||||
updateSequence: -1,
|
||||
currentCreditBytes: 0,
|
||||
queue: [],
|
||||
readers: [],
|
||||
closed: false,
|
||||
error: null,
|
||||
};
|
||||
incoming.set(frame.streamId, state);
|
||||
const waiter = pendingIncoming.get(frame.streamId);
|
||||
pendingIncoming.delete(frame.streamId);
|
||||
waiter.resolve(readableHandle(state));
|
||||
return true;
|
||||
}
|
||||
const output = outgoing.get(frame.streamId);
|
||||
if (output && frame.kind === "windowUpdate") {
|
||||
if (frame.sequence !== output.lastUpdateSequence + 1) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit is out of order: ${frame.streamId}`);
|
||||
}
|
||||
output.lastUpdateSequence = frame.sequence;
|
||||
output.availableBytes += frame.creditBytes;
|
||||
if (output.availableBytes > output.windowBytes) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit exceeds its window: ${frame.streamId}`);
|
||||
}
|
||||
if (output.terminalSent && output.availableBytes === output.windowBytes) {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
} else flushOutgoing(output);
|
||||
return true;
|
||||
}
|
||||
if (output && frame.kind === "cancel") {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
const error = new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`);
|
||||
for (const pending of output.queue.splice(0)) pending.reject(error);
|
||||
output.queuedBytes = 0;
|
||||
output.endReject?.(error);
|
||||
return true;
|
||||
}
|
||||
const input = incoming.get(frame.streamId);
|
||||
if (!input || input.closed) throw new PluginError("data_loss", `Unknown Plugin stream: ${frame.streamId}`);
|
||||
if (frame.sequence !== input.nextSequence) {
|
||||
throw new PluginError("data_loss", `Plugin stream frame is out of order: ${frame.streamId}`);
|
||||
}
|
||||
input.nextSequence += 1;
|
||||
if (frame.kind === "chunk") {
|
||||
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
|
||||
if (materialized.encoding !== "binary" || !(materialized.bytes instanceof Uint8Array)) {
|
||||
throw new PluginError("data_loss", "Plugin byte stream received a non-binary chunk");
|
||||
}
|
||||
const data = materialized.bytes;
|
||||
if (data.byteLength > input.availableBytes) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream exceeded receive credit: ${frame.streamId}`);
|
||||
}
|
||||
input.availableBytes -= data.byteLength;
|
||||
input.queue.push({ data, creditBytes: data.byteLength });
|
||||
settleIncomingWaiters(input);
|
||||
return true;
|
||||
}
|
||||
closeIncoming(
|
||||
input,
|
||||
frame.kind === "error"
|
||||
? new PluginError("data_loss", frame.error.message)
|
||||
: frame.kind === "cancel"
|
||||
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
|
||||
: null,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
accept,
|
||||
async acceptReadable(streamId) {
|
||||
const id = assertStreamId(streamId);
|
||||
const existing = incoming.get(id);
|
||||
if (existing) return readableHandle(existing);
|
||||
if (closed || pendingIncoming.has(id) || outgoing.has(id)) {
|
||||
throw new PluginError("failed_precondition", `Plugin stream cannot be accepted: ${id}`);
|
||||
}
|
||||
if (reservedStreams() >= maxStreams) {
|
||||
throw new PluginError("resource_exhausted", "Plugin stream limit is exhausted");
|
||||
}
|
||||
return new Promise((resolve, reject) => pendingIncoming.set(id, { resolve, reject }));
|
||||
},
|
||||
rejectReadable(streamId, error = new PluginError("cancelled", "Plugin stream acceptance was cancelled")) {
|
||||
const id = assertStreamId(streamId);
|
||||
const waiter = pendingIncoming.get(id);
|
||||
if (!waiter) return false;
|
||||
pendingIncoming.delete(id);
|
||||
waiter.reject(error);
|
||||
return true;
|
||||
},
|
||||
async openWritable(streamId, windowBytes = 256 * 1024) {
|
||||
const id = assertStreamId(streamId);
|
||||
const window = assertWindowBytes(windowBytes);
|
||||
if (closed || reservedStreams() >= maxStreams || incoming.has(id) || outgoing.has(id) || pendingIncoming.has(id)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${id}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: id,
|
||||
windowBytes: window,
|
||||
availableBytes: window,
|
||||
nextSequence: 0,
|
||||
lastUpdateSequence: -1,
|
||||
queue: [],
|
||||
queuedBytes: 0,
|
||||
terminal: null,
|
||||
terminalSent: false,
|
||||
endPromise: null,
|
||||
endResolve: null,
|
||||
endReject: null,
|
||||
closed: false,
|
||||
};
|
||||
outgoing.set(id, state);
|
||||
sendEnvelope({ streamId: id, sequence: 0, kind: "open", windowBytes: window });
|
||||
return writableHandle(state);
|
||||
},
|
||||
close(error = new PluginError("unavailable", "Plugin stream transport closed")) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const state of incoming.values()) closeIncoming(state, error);
|
||||
for (const state of outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
outgoing.clear();
|
||||
for (const waiter of pendingIncoming.values()) waiter.reject(error);
|
||||
pendingIncoming.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { assertStreamId, assertWindowBytes };
|
||||
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
@@ -0,0 +1,957 @@
|
||||
import {
|
||||
CancellationTokenSource,
|
||||
DisposableStore,
|
||||
PluginError,
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
function loadTerminalInterceptorTransport() {
|
||||
terminalInterceptorTransportPromise ??= import("../terminalInterceptorTransport.cjs")
|
||||
.then((module) => module.default);
|
||||
return terminalInterceptorTransportPromise;
|
||||
}
|
||||
|
||||
const RPC_ERRORS = {
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internal: -32603,
|
||||
cancelled: -32001,
|
||||
unsupported: -32012,
|
||||
};
|
||||
|
||||
const PLUGIN_ERROR_NAMES_BY_WIRE_CODE = new Map(
|
||||
Object.entries(PLUGIN_ERROR_WIRE_CODES).map(([name, code]) => [code, name]),
|
||||
);
|
||||
|
||||
const PROVIDER_KINDS = new Set([
|
||||
"terminal.completion",
|
||||
"terminal.decoration",
|
||||
"terminal.link",
|
||||
"terminal.hover",
|
||||
"terminal.matcher",
|
||||
"terminal.semantic",
|
||||
"terminal.prompt",
|
||||
"terminal.background",
|
||||
"terminal.theme",
|
||||
"terminal.interceptor.input",
|
||||
"terminal.interceptor.output",
|
||||
"connection",
|
||||
"authentication",
|
||||
"sync",
|
||||
"importer",
|
||||
]);
|
||||
|
||||
const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"validateConfiguration",
|
||||
"probe",
|
||||
"open",
|
||||
"resize",
|
||||
"signal",
|
||||
"reconnect",
|
||||
"close",
|
||||
"getStatus",
|
||||
]);
|
||||
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
|
||||
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"connect",
|
||||
"disconnect",
|
||||
"getAccount",
|
||||
"getCapabilities",
|
||||
"readObject",
|
||||
"writeObject",
|
||||
"deleteObject",
|
||||
]);
|
||||
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
|
||||
|
||||
function pluginErrorNameFromRpcError(error) {
|
||||
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
|
||||
if (error?.code === RPC_ERRORS.methodNotFound) return "unsupported";
|
||||
if (error?.code === RPC_ERRORS.invalidParams) return "invalid_argument";
|
||||
if (error?.code === RPC_ERRORS.internal) return "internal";
|
||||
return PLUGIN_ERROR_NAMES_BY_WIRE_CODE.get(error?.code) ?? "unknown";
|
||||
}
|
||||
|
||||
function freezeRuntimeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
function closeTransferredPorts(ports) {
|
||||
for (const port of ports ?? []) port?.close?.();
|
||||
}
|
||||
|
||||
function createTransportAdapter(port) {
|
||||
const listeners = new Set();
|
||||
const handle = (event) => {
|
||||
const value = messageData(event);
|
||||
for (const listener of listeners) listener(value, event?.ports ?? []);
|
||||
};
|
||||
if (typeof port.addEventListener === "function") port.addEventListener("message", handle);
|
||||
else port.on("message", handle);
|
||||
port.start?.();
|
||||
return {
|
||||
post(message, transfer = []) {
|
||||
port.postMessage(message, transfer);
|
||||
},
|
||||
onMessage(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
listeners.clear();
|
||||
port.close?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRpcFailure(id, code, message, data) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code, message, ...(data === undefined ? {} : { data }) },
|
||||
};
|
||||
}
|
||||
|
||||
function cancelUnhandledStream(transport, message) {
|
||||
const keys = Reflect.ownKeys(message);
|
||||
if (keys.some((key) => typeof key !== "string" || (key !== "frame" && key !== "transfer"))) {
|
||||
throw new TypeError("Plugin stream envelope contains unknown properties");
|
||||
}
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin stream envelope must contain enumerable data properties");
|
||||
}
|
||||
}
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
if (envelope.frame.kind !== "open") {
|
||||
throw new Error(`Unknown incoming plugin stream: ${envelope.frame.streamId}`);
|
||||
}
|
||||
transport.post(createMessagePortStreamEnvelope({
|
||||
streamId: envelope.frame.streamId,
|
||||
sequence: 1,
|
||||
kind: "cancel",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeError(error) {
|
||||
if (error instanceof PluginError) {
|
||||
return pluginErrorToRpcError(error);
|
||||
}
|
||||
return { code: RPC_ERRORS.internal, message: "Plugin operation failed" };
|
||||
}
|
||||
|
||||
function createHostClient(transport) {
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "id")) return false;
|
||||
if (!Object.hasOwn(message, "result") && !Object.hasOwn(message, "error")) return false;
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
message.error.message,
|
||||
message.error.data?.details,
|
||||
));
|
||||
} else request.resolve(message.result);
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
transport.post({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
}
|
||||
|
||||
function assertStorageKey(key) {
|
||||
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin storage key is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function assertCredentialRef(credential) {
|
||||
if (
|
||||
!credential
|
||||
|| typeof credential !== "object"
|
||||
|| (credential.kind !== "secret" && credential.kind !== "credential")
|
||||
|| typeof credential.id !== "string"
|
||||
|| credential.id.length < 16
|
||||
|| credential.id.length > 256
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
if (
|
||||
credential.kind === "secret"
|
||||
&& (
|
||||
typeof credential.key !== "string"
|
||||
|| credential.key.length < 1
|
||||
|| credential.key.length > 256
|
||||
|| credential.key.includes("\0")
|
||||
)
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
return credential.kind === "secret"
|
||||
? { kind: "secret", id: credential.id, key: credential.key }
|
||||
: { kind: "credential", id: credential.id };
|
||||
}
|
||||
|
||||
function forwardedDeadline(value, maximum) {
|
||||
return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : undefined;
|
||||
}
|
||||
|
||||
function assertCompanionId(companionId) {
|
||||
if (typeof companionId !== "string" || companionId.length < 5 || companionId.length > 192) {
|
||||
throw new PluginError("invalid_argument", "Companion ID is invalid");
|
||||
}
|
||||
return companionId;
|
||||
}
|
||||
|
||||
function assertOwnedContributionId(pluginId, id, label) {
|
||||
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`) || id.length > 256) {
|
||||
throw new PluginError("invalid_argument", `${label} ID is invalid`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function assertProviderKind(kind) {
|
||||
if (!PROVIDER_KINDS.has(kind)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider kind is invalid");
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationMapLabel(kind) {
|
||||
if (kind === "connection") return "Connection";
|
||||
if (kind === "importer") return "Importer";
|
||||
if (kind === "sync") return "Sync";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationsForProviderKind(kind) {
|
||||
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
|
||||
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
|
||||
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProviderHandler(kind, handler) {
|
||||
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
const label = operationMapLabel(kind);
|
||||
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
|
||||
}
|
||||
const normalized = {};
|
||||
const operations = operationsForProviderKind(kind);
|
||||
for (const operation of operations) {
|
||||
if (typeof handler[operation] !== "function") {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
|
||||
}
|
||||
normalized[operation] = handler[operation].bind(handler);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function assertOwnedContextKey(pluginId, key) {
|
||||
const prefix = `${pluginId}.`;
|
||||
const suffix = typeof key === "string" && key.startsWith(prefix)
|
||||
? key.slice(prefix.length)
|
||||
: "";
|
||||
if (!/^[A-Za-z0-9_][A-Za-z0-9_:-]{0,255}$/u.test(suffix)
|
||||
|| key.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Plugin Context Key ID is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizeRuntimeEnvironment(value) {
|
||||
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
const sourceTokens = source.themeTokens && typeof source.themeTokens === "object"
|
||||
&& !Array.isArray(source.themeTokens)
|
||||
? source.themeTokens
|
||||
: {};
|
||||
const themeTokens = Object.freeze(Object.fromEntries(
|
||||
Object.entries(sourceTokens).filter(([key, token]) => key.length > 0 && typeof token === "string"),
|
||||
));
|
||||
return Object.freeze({
|
||||
locale: typeof source.locale === "string" ? source.locale : "en",
|
||||
theme: typeof source.theme === "string" ? source.theme : "system",
|
||||
reducedMotion: source.reducedMotion === true,
|
||||
highContrast: source.highContrast === true,
|
||||
themeTokens,
|
||||
});
|
||||
}
|
||||
|
||||
function createEmitter() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event(listener) {
|
||||
if (typeof listener !== "function") throw new PluginError("invalid_argument", "Plugin event listener must be a function");
|
||||
listeners.add(listener);
|
||||
return Object.freeze({ dispose: () => listeners.delete(listener) });
|
||||
},
|
||||
fire(value) {
|
||||
for (const listener of [...listeners]) {
|
||||
try { listener(value); } catch {}
|
||||
}
|
||||
},
|
||||
clear() { listeners.clear(); },
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginContext(config, client, runtimeApi) {
|
||||
const subscriptions = new DisposableStore();
|
||||
const storage = {
|
||||
get: (key) => client.request("storage.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.value : undefined),
|
||||
set: (key, value) => client.request("storage.set", { key: assertStorageKey(key), value })
|
||||
.then(() => undefined),
|
||||
delete: (key) => client.request("storage.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
keys: () => client.request("storage.keys", {}).then((result) => result?.keys ?? []),
|
||||
};
|
||||
const secrets = {
|
||||
get: (key) => client.request("secrets.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.secret : undefined),
|
||||
set: (key, value) => client.request("secrets.set", {
|
||||
key: assertStorageKey(key),
|
||||
value,
|
||||
}).then((result) => result.secret),
|
||||
delete: (key) => client.request("secrets.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
};
|
||||
const credentials = {
|
||||
createLease: (secret, options) => client.request("credentials.createLease", {
|
||||
secret: assertCredentialRef(secret),
|
||||
operationId: options?.operationId,
|
||||
purpose: options?.purpose,
|
||||
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
|
||||
}),
|
||||
};
|
||||
const network = {
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
...options,
|
||||
}).then((result) => result.data),
|
||||
writeFile: (filePath, data, options = {}) => client.request("filesystem.writeFile", {
|
||||
path: filePath,
|
||||
data,
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
const result = await client.request("companion.start", {
|
||||
companionId: assertCompanionId(companionId),
|
||||
});
|
||||
let stopped = false;
|
||||
let stopPromise = null;
|
||||
const stop = () => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = client.request("companion.stop", { handleId: result.handleId })
|
||||
.then(() => { stopped = true; })
|
||||
.finally(() => { stopPromise = null; });
|
||||
return stopPromise;
|
||||
};
|
||||
return Object.freeze({
|
||||
id: result.handleId,
|
||||
request: (method, params, options = {}) => client.request(
|
||||
"companion.request",
|
||||
{
|
||||
handleId: result.handleId,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.credentialLeases === undefined ? {} : {
|
||||
credentialLeases: options.credentialLeases,
|
||||
operationId: options.operationId,
|
||||
}),
|
||||
},
|
||||
{ deadlineMs: forwardedDeadline(options.timeoutMs, 60_000) },
|
||||
),
|
||||
stop,
|
||||
dispose() { void stop().catch(() => {}); },
|
||||
});
|
||||
},
|
||||
};
|
||||
const streams = {
|
||||
acceptReadable: (streamId) => runtimeApi.streams.acceptReadable(streamId),
|
||||
openWritable: (streamId, options = {}) => runtimeApi.streams.openWritable(
|
||||
streamId,
|
||||
options.windowBytes,
|
||||
),
|
||||
};
|
||||
const settings = {
|
||||
get: (settingId, options = {}) => client.request("settings.get", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result?.found ? result.value : undefined),
|
||||
update: (settingId, value, options = {}) => client.request("settings.update", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
value,
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result ?? { restartRequired: false }),
|
||||
onDidChange: runtimeApi.settingsChanged.event,
|
||||
};
|
||||
const commands = {
|
||||
registerCommand(commandId, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, commandId, "Plugin command");
|
||||
if (typeof handler !== "function") throw new PluginError("invalid_argument", "Plugin command handler must be a function");
|
||||
if (runtimeApi.commandHandlers.has(id)) throw new PluginError("already_exists", `Plugin command is already registered: ${id}`);
|
||||
runtimeApi.commandHandlers.set(id, handler);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.commandHandlers.get(id) === handler) {
|
||||
runtimeApi.commandHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
executeCommand: (commandId, args) => client.request("commands.execute", {
|
||||
command: assertOwnedContributionId(config.pluginId, commandId, "Plugin command"),
|
||||
...(args === undefined ? {} : { args }),
|
||||
}),
|
||||
};
|
||||
const contextKeys = {
|
||||
set: (key, value) => client.request("contextKeys.set", {
|
||||
key: assertOwnedContextKey(config.pluginId, key),
|
||||
value,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const views = {
|
||||
onDidReceiveMessage(viewId, listener) {
|
||||
const id = assertOwnedContributionId(config.pluginId, viewId, "Plugin view");
|
||||
let emitter = runtimeApi.viewMessages.get(id);
|
||||
if (!emitter) {
|
||||
emitter = createEmitter();
|
||||
runtimeApi.viewMessages.set(id, emitter);
|
||||
}
|
||||
return emitter.event(listener);
|
||||
},
|
||||
postMessage: (viewId, message) => client.notify("views.postMessage", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
message,
|
||||
}),
|
||||
getState: (viewId, scopeId) => client.request("views.getState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
}).then((result) => result?.state),
|
||||
setState: (viewId, scopeId, state) => client.request("views.setState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
state,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const providers = {
|
||||
register(providerId, kind, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, providerId, "Plugin Provider");
|
||||
const normalizedKind = assertProviderKind(kind);
|
||||
const normalizedHandler = normalizeProviderHandler(normalizedKind, handler);
|
||||
if (runtimeApi.providerHandlers.has(id)) {
|
||||
throw new PluginError("already_exists", `Plugin Provider is already registered: ${id}`);
|
||||
}
|
||||
const registration = Object.freeze({ kind: normalizedKind, handler: normalizedHandler });
|
||||
runtimeApi.providerHandlers.set(id, registration);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.providerHandlers.get(id) === registration) {
|
||||
runtimeApi.providerHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const terminals = {
|
||||
onDidChange: runtimeApi.terminalEvents.event,
|
||||
};
|
||||
const environment = {
|
||||
get locale() { return runtimeApi.environment.locale ?? "en"; },
|
||||
get theme() { return runtimeApi.environment.theme ?? "system"; },
|
||||
get reducedMotion() { return runtimeApi.environment.reducedMotion === true; },
|
||||
get highContrast() { return runtimeApi.environment.highContrast === true; },
|
||||
get themeTokens() { return runtimeApi.environment.themeTokens; },
|
||||
onDidChange: runtimeApi.environmentChanged.event,
|
||||
};
|
||||
const logger = Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [
|
||||
level,
|
||||
(message, fields) => client.notify("log.write", {
|
||||
level,
|
||||
message: String(message).slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields }),
|
||||
}),
|
||||
]));
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
netcattyVersion: config.netcattyVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: new Set(config.enabledFeatures),
|
||||
subscriptions,
|
||||
storage,
|
||||
settings,
|
||||
commands,
|
||||
contextKeys,
|
||||
views,
|
||||
providers,
|
||||
terminals,
|
||||
environment,
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
filesystem,
|
||||
companions,
|
||||
streams,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPluginRuntime({
|
||||
port,
|
||||
config,
|
||||
loadPlugin,
|
||||
loadTerminalInterceptorTransport: loadTerminalInterceptorTransportForRuntime = loadTerminalInterceptorTransport,
|
||||
}) {
|
||||
const transport = createTransportAdapter(port);
|
||||
const client = createHostClient(transport);
|
||||
const cancellation = new Map();
|
||||
let plugin;
|
||||
let context;
|
||||
let activated = false;
|
||||
let deactivated = false;
|
||||
const runtimeApi = {
|
||||
commandHandlers: new Map(),
|
||||
providerHandlers: new Map(),
|
||||
settingsChanged: createEmitter(),
|
||||
environmentChanged: createEmitter(),
|
||||
terminalEvents: createEmitter(),
|
||||
environment: normalizeRuntimeEnvironment(config.environment),
|
||||
viewMessages: new Map(),
|
||||
terminalInterceptorPorts: new Set(),
|
||||
streams: null,
|
||||
};
|
||||
runtimeApi.streams = createPluginStreamEndpoint(transport);
|
||||
const pluginModule = await loadPlugin(config.entryUrl);
|
||||
plugin = pluginModule?.default;
|
||||
if (!plugin || typeof plugin.activate !== "function") {
|
||||
throw new Error("Plugin entrypoint must default-export a plugin with activate(context)");
|
||||
}
|
||||
|
||||
async function handleRequest(message, cancellationToken, ports = []) {
|
||||
const isTerminalAttachment = message.method === "plugin.terminal.interceptor.attach";
|
||||
if (!isTerminalAttachment) closeTransferredPorts(ports);
|
||||
if (message.method === "plugin.initialize") {
|
||||
if (context) throw new PluginError("failed_precondition", "Plugin is already initialized");
|
||||
context = createPluginContext(config, client, runtimeApi);
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: [...config.enabledFeatures],
|
||||
};
|
||||
}
|
||||
if (message.method === "plugin.activate") {
|
||||
if (!context) throw new PluginError("failed_precondition", "Plugin must be initialized first");
|
||||
if (!activated) {
|
||||
if (message.params?.environment && typeof message.params.environment === "object"
|
||||
&& !Array.isArray(message.params.environment)) {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params.environment);
|
||||
}
|
||||
const disposable = await plugin.activate(context);
|
||||
if (disposable && typeof disposable.dispose === "function") context.subscriptions.add(disposable);
|
||||
activated = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.deactivate") {
|
||||
if (!deactivated) {
|
||||
deactivated = true;
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.terminal.interceptor.attach") {
|
||||
if (ports.length !== 1) {
|
||||
closeTransferredPorts(ports);
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor attachment requires exactly one port");
|
||||
}
|
||||
try {
|
||||
await attachTerminalInterceptor(message.params, ports, cancellationToken);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
closeTransferredPorts(ports);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (message.method === "plugin.command.execute") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const command = assertOwnedContributionId(config.pluginId, message.params?.command, "Plugin command");
|
||||
const handler = runtimeApi.commandHandlers.get(command);
|
||||
if (!handler) throw new PluginError("failed_precondition", `Plugin command has no registered handler: ${command}`);
|
||||
return await handler(message.params?.args, message.params?.invocation);
|
||||
}
|
||||
if (message.method === "provider.invoke") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const providerId = assertOwnedContributionId(config.pluginId, message.params?.providerId, "Plugin Provider");
|
||||
const kind = assertProviderKind(message.params?.kind);
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (!registration) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider has no registered handler: ${providerId}`);
|
||||
}
|
||||
if (registration.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider kind changed: ${providerId}`);
|
||||
}
|
||||
const requestId = message.params?.requestId;
|
||||
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider request ID is invalid");
|
||||
}
|
||||
const operation = message.params?.operation;
|
||||
if (typeof operation !== "string" || operation.length < 1 || operation.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider operation is invalid");
|
||||
}
|
||||
const deadlineMs = message.params?.deadlineMs;
|
||||
if (deadlineMs != null && (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider deadline is invalid");
|
||||
}
|
||||
const payload = message.params?.payload === undefined
|
||||
? undefined
|
||||
: freezeRuntimeJson(message.params.payload);
|
||||
const connectionOpen = kind === "connection" && operation === "open";
|
||||
const importerParse = kind === "importer" && operation === "parse";
|
||||
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
|
||||
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
|
||||
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
|
||||
let input;
|
||||
let output;
|
||||
let cancelStreams;
|
||||
if (streamed) {
|
||||
const inputStreamId = payload?.inputStreamId;
|
||||
const outputStreamId = payload?.outputStreamId;
|
||||
const windowBytes = payload?.windowBytes;
|
||||
if (connectionOpen || importerParse) {
|
||||
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
|
||||
}
|
||||
} else if (syncReadStream && typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
|
||||
} else if (syncWriteStream && typeof inputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
|
||||
}
|
||||
if (typeof inputStreamId === "string") {
|
||||
input = runtimeApi.streams.acceptReadable(inputStreamId);
|
||||
// Prevent a cancelled request from creating an unhandled rejected
|
||||
// promise when the Provider never awaited its input stream.
|
||||
void input.catch(() => {});
|
||||
}
|
||||
if (typeof outputStreamId === "string") {
|
||||
try {
|
||||
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
|
||||
} catch (error) {
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
cancelStreams = () => {
|
||||
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
void input?.then((stream) => stream.cancel(), () => {});
|
||||
}
|
||||
output?.cancel?.();
|
||||
};
|
||||
}
|
||||
const cancellationDisposable = cancelStreams
|
||||
? cancellationToken.onCancellationRequested(cancelStreams)
|
||||
: null;
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
operation,
|
||||
requestId,
|
||||
payload,
|
||||
deadlineMs,
|
||||
cancellationToken,
|
||||
...(input !== undefined ? { input } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
});
|
||||
try {
|
||||
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
|
||||
? registration.handler[operation]
|
||||
: registration.handler;
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", `${kind} Provider operation is not implemented: ${operation}`);
|
||||
}
|
||||
const result = await handler(invocation);
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
// Sync readObject may return inline base64 or found:false while the host
|
||||
// still opened an output stream for large-object fallback — release it.
|
||||
if (syncReadStream && output && !(result && result.streamed === true)) {
|
||||
try { output.cancel?.(); } catch { /* best-effort */ }
|
||||
}
|
||||
return { requestId, status: "ok", result: result === undefined ? null : result };
|
||||
} catch (error) {
|
||||
cancelStreams?.();
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
const rpcError = normalizeError(error);
|
||||
return {
|
||||
requestId,
|
||||
status: "failed",
|
||||
error: { code: rpcError.code, message: rpcError.message, ...(rpcError.data === undefined ? {} : { data: rpcError.data }) },
|
||||
};
|
||||
} finally {
|
||||
cancellationDisposable?.dispose();
|
||||
if (streamed && cancellationToken.isCancellationRequested) cancelStreams();
|
||||
}
|
||||
}
|
||||
throw new PluginError("unsupported", `Unsupported host method: ${message.method}`);
|
||||
}
|
||||
|
||||
function handleNotification(message) {
|
||||
if (message.method === "plugin.settings.changed") {
|
||||
runtimeApi.settingsChanged.fire(message.params);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.environment.changed") {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params);
|
||||
runtimeApi.environmentChanged.fire(runtimeApi.environment);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.view.message") {
|
||||
const viewId = assertOwnedContributionId(config.pluginId, message.params?.viewId, "Plugin view");
|
||||
runtimeApi.viewMessages.get(viewId)?.fire(message.params?.message);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.terminal.event") {
|
||||
runtimeApi.terminalEvents.fire(freezeRuntimeJson(message.params));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachTerminalInterceptor = async (message, ports, cancellationToken) => {
|
||||
const descriptor = message?.descriptor;
|
||||
const providerId = assertOwnedContributionId(config.pluginId, descriptor?.providerId, "Terminal interceptor");
|
||||
const direction = descriptor?.direction;
|
||||
const kind = direction === "input" ? "terminal.interceptor.input"
|
||||
: direction === "output" ? "terminal.interceptor.output" : null;
|
||||
const sessionId = descriptor?.session?.sessionId;
|
||||
if (!kind || typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor descriptor is invalid");
|
||||
}
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
const interceptorPort = ports?.[0];
|
||||
if (!activated || registration?.kind !== kind || !interceptorPort?.postMessage) {
|
||||
interceptorPort?.close?.();
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is not registered");
|
||||
}
|
||||
runtimeApi.terminalInterceptorPorts.add(interceptorPort);
|
||||
let attached = false;
|
||||
try {
|
||||
const {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = await loadTerminalInterceptorTransportForRuntime();
|
||||
if (cancellationToken?.isCancellationRequested
|
||||
|| deactivated
|
||||
|| !activated
|
||||
|| !runtimeApi.terminalInterceptorPorts.has(interceptorPort)
|
||||
|| runtimeApi.providerHandlers.get(providerId) !== registration) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor attachment was cancelled or became stale");
|
||||
}
|
||||
const handleChunk = (event) => {
|
||||
let envelope;
|
||||
try {
|
||||
const message = messageData(event);
|
||||
envelope = createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
|
||||
} catch {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const chunk = envelope.frame;
|
||||
if (chunk?.type === "netcatty:terminal-interceptor:ready") return;
|
||||
if (chunk.type !== "netcatty:terminal-interceptor:chunk"
|
||||
|| chunk.direction !== direction) {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
direction,
|
||||
sequence: chunk.sequence,
|
||||
session: freezeRuntimeJson(descriptor.session),
|
||||
data: new Uint8Array(envelope.transfer),
|
||||
});
|
||||
// Resolve the registration for every chunk so disposal/re-registration
|
||||
// cannot keep a captured stale handler alive. Starting from a resolved
|
||||
// promise also converts synchronous handler throws into the ordinary
|
||||
// failed response path instead of tearing down the utility runtime.
|
||||
void Promise.resolve().then(() => {
|
||||
const currentRegistration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (currentRegistration?.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is no longer registered");
|
||||
}
|
||||
return currentRegistration.handler(invocation);
|
||||
}).then(
|
||||
(result) => {
|
||||
let bytes;
|
||||
if (result instanceof Uint8Array) bytes = result;
|
||||
else if (result instanceof ArrayBuffer) bytes = new Uint8Array(result);
|
||||
else throw new PluginError("data_loss", "Terminal interceptor must return Uint8Array or ArrayBuffer");
|
||||
if (bytes.byteLength > TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES) {
|
||||
throw new PluginError("resource_exhausted", "Terminal interceptor result is too large");
|
||||
}
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const resultEnvelope = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "ok",
|
||||
creditBytes: chunk.byteLength,
|
||||
byteLength: copy.byteLength,
|
||||
}, copy.buffer);
|
||||
interceptorPort.postMessage(resultEnvelope, [copy.buffer]);
|
||||
},
|
||||
() => interceptorPort.postMessage(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "failed",
|
||||
})),
|
||||
).catch(() => interceptorPort.close?.());
|
||||
};
|
||||
if (typeof interceptorPort.addEventListener === "function") {
|
||||
interceptorPort.addEventListener("message", handleChunk);
|
||||
} else {
|
||||
interceptorPort.on("message", handleChunk);
|
||||
}
|
||||
interceptorPort.start?.();
|
||||
interceptorPort.on?.("close", () => runtimeApi.terminalInterceptorPorts.delete(interceptorPort));
|
||||
attached = true;
|
||||
} finally {
|
||||
if (!attached) {
|
||||
runtimeApi.terminalInterceptorPorts.delete(interceptorPort);
|
||||
interceptorPort.close?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = transport.onMessage((message, ports) => {
|
||||
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try {
|
||||
if (!runtimeApi.streams.accept(message)) cancelUnhandledStream(transport, message);
|
||||
}
|
||||
catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
if (client.accept(message)) {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (!message || message.jsonrpc !== "2.0") {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/cancelRequest") {
|
||||
for (const port of ports) port?.close?.();
|
||||
cancellation.get(message.params?.cancellationId)?.cancel();
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(message, "id")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try { handleNotification(message); } catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
const cancellationId = message.cancellationId;
|
||||
const source = new CancellationTokenSource();
|
||||
if (cancellationId) cancellation.set(cancellationId, source);
|
||||
void handleRequest(message, source.token, ports).then(
|
||||
(result) => transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: result === undefined ? null : result,
|
||||
}),
|
||||
(error) => {
|
||||
const rpcError = normalizeError(error);
|
||||
transport.post(makeRpcFailure(message.id, rpcError.code, rpcError.message, rpcError.data));
|
||||
},
|
||||
).finally(() => {
|
||||
if (cancellationId) cancellation.delete(cancellationId);
|
||||
source.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
dispose();
|
||||
client.close();
|
||||
for (const source of cancellation.values()) source.cancel();
|
||||
runtimeApi.commandHandlers.clear();
|
||||
runtimeApi.providerHandlers.clear();
|
||||
runtimeApi.settingsChanged.clear();
|
||||
runtimeApi.environmentChanged.clear();
|
||||
runtimeApi.terminalEvents.clear();
|
||||
for (const interceptorPort of runtimeApi.terminalInterceptorPorts) interceptorPort.close?.();
|
||||
runtimeApi.terminalInterceptorPorts.clear();
|
||||
runtimeApi.streams.close();
|
||||
for (const emitter of runtimeApi.viewMessages.values()) emitter.clear();
|
||||
cancellation.clear();
|
||||
if (!deactivated) {
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
transport.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = require("../terminalInterceptorTransport.cjs");
|
||||
|
||||
class FakePort {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
this.messages = [];
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
postMessage(message, transfer = []) {
|
||||
this.messages.push({ message, transfer });
|
||||
}
|
||||
|
||||
start() {}
|
||||
|
||||
close() { this.closed = true; }
|
||||
|
||||
emit(data, ports = []) {
|
||||
for (const listener of this.listeners) listener({ data, ports });
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test("utility runtime dispatches dedicated terminal ports to the exact registered interceptor", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => Uint8Array.from([...data].map((byte) => byte >= 97 && byte <= 122 ? byte - 32 : byte)),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.result?.accepted === true
|
||||
)),
|
||||
true,
|
||||
);
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
windowBytes: 64 * 1024,
|
||||
}));
|
||||
assert.equal(dataPort.closed, false);
|
||||
const data = Uint8Array.from(Buffer.from("hello")).buffer;
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
}, data));
|
||||
await tick();
|
||||
assert.equal(dataPort.messages.length, 1);
|
||||
const result = createTerminalInterceptorEnvelope(
|
||||
dataPort.messages[0].message.frame,
|
||||
dataPort.messages[0].message.transfer,
|
||||
);
|
||||
assert.equal(result.frame.status, "ok");
|
||||
assert.equal(result.frame.creditBytes, 5);
|
||||
assert.equal(Buffer.from(result.transfer).toString("utf8"), "HELLO");
|
||||
assert.deepEqual(dataPort.messages[0].transfer, [result.transfer]);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes a terminal port when provider ownership or kind is invalid", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.missing",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.error?.code === -32009
|
||||
)),
|
||||
true,
|
||||
);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes the transferred terminal port when the provider belongs to another plugin", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "other.plugin.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === 3
|
||||
&& message.error?.code === -32003
|
||||
)),
|
||||
true,
|
||||
);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime closes unexpected transferred ports on every lifecycle request", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
const lifecycleRequests = [
|
||||
{ id: 1, method: "plugin.initialize", params: {} },
|
||||
{ id: 2, method: "plugin.activate", params: {} },
|
||||
{ id: 3, method: "plugin.deactivate", params: {} },
|
||||
];
|
||||
for (const request of lifecycleRequests) {
|
||||
const unexpectedPort = new FakePort();
|
||||
control.emit({ jsonrpc: "2.0", ...request }, [unexpectedPort]);
|
||||
await tick();
|
||||
assert.equal(unexpectedPort.closed, true, `${request.method} retained an unexpected port`);
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => (
|
||||
message.jsonrpc === "2.0"
|
||||
&& message.id === request.id
|
||||
&& Object.hasOwn(message, "result")
|
||||
)),
|
||||
true,
|
||||
`${request.method} did not complete after closing its unexpected port`,
|
||||
);
|
||||
}
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime rejects the retired private terminal attachment protocol", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({ default: { activate() {} } }),
|
||||
});
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
type: "netcatty-plugin:terminal-interceptor:attach",
|
||||
attachmentId: 1,
|
||||
descriptor: { providerId: "com.example.input", direction: "input" },
|
||||
}, [dataPort]);
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(control.messages.length, 0);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("terminal ports convert synchronous throws to failures and stop using disposed handlers", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
let registration;
|
||||
let calls = 0;
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
registration = context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
() => {
|
||||
calls += 1;
|
||||
throw new Error("synchronous failure");
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
const send = (sequence) => {
|
||||
const data = Uint8Array.from([sequence]).buffer;
|
||||
dataPort.emit(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
}, data));
|
||||
};
|
||||
send(1);
|
||||
await tick();
|
||||
assert.equal(dataPort.messages[0].message.frame.status, "failed");
|
||||
assert.equal(calls, 1);
|
||||
|
||||
registration.dispose();
|
||||
send(2);
|
||||
await tick();
|
||||
assert.equal(dataPort.messages[1].message.frame.status, "failed");
|
||||
assert.equal(calls, 1);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("the utility peer closes a port whose chunk bypasses the canonical terminal frame schema", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => data,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
const data = Uint8Array.from(Buffer.from("unsafe")).buffer;
|
||||
dataPort.emit({
|
||||
frame: {
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 64 * 1024,
|
||||
byteLength: data.byteLength,
|
||||
extra: true,
|
||||
},
|
||||
transfer: data,
|
||||
});
|
||||
assert.equal(dataPort.closed, true);
|
||||
assert.equal(dataPort.messages.length, 0);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test("utility runtime disposal closes a terminal port while its transport helper is loading", async () => {
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
const control = new FakePort();
|
||||
let resolveTransport;
|
||||
const transportLoading = new Promise((resolve) => { resolveTransport = resolve; });
|
||||
const runtime = await startPluginRuntime({
|
||||
port: control,
|
||||
config: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "1.0.0",
|
||||
enabledFeatures: [],
|
||||
environment: {},
|
||||
entryUrl: "file:///plugin.js",
|
||||
},
|
||||
loadPlugin: async () => ({
|
||||
default: {
|
||||
activate(context) {
|
||||
context.providers.register(
|
||||
"com.example.input",
|
||||
"terminal.interceptor.input",
|
||||
({ data }) => data,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
loadTerminalInterceptorTransport: () => transportLoading,
|
||||
});
|
||||
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
|
||||
await tick();
|
||||
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
|
||||
await tick();
|
||||
|
||||
const dataPort = new FakePort();
|
||||
control.emit({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "plugin.terminal.interceptor.attach",
|
||||
params: {
|
||||
descriptor: {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||||
},
|
||||
},
|
||||
}, [dataPort]);
|
||||
await tick();
|
||||
assert.equal(dataPort.closed, false);
|
||||
|
||||
await runtime.dispose();
|
||||
assert.equal(dataPort.closed, true);
|
||||
resolveTransport({
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES: 64 * 1024,
|
||||
createTerminalInterceptorEnvelope,
|
||||
});
|
||||
await tick();
|
||||
assert.equal(
|
||||
control.messages.some(({ message }) => message.id === 3 && message.result?.accepted === true),
|
||||
false,
|
||||
);
|
||||
assert.equal(dataPort.closed, true);
|
||||
});
|
||||
24
electron/plugins/runtime/utilityRuntime.mjs
Normal file
24
electron/plugins/runtime/utilityRuntime.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { register } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
const bootstrap = await new Promise((resolve) => {
|
||||
process.parentPort.once("message", (event) => resolve(messageData(event)));
|
||||
});
|
||||
if (bootstrap?.type !== "netcatty-plugin:bootstrap") {
|
||||
throw new Error("Missing plugin utility runtime bootstrap");
|
||||
}
|
||||
register(new URL("./pluginModuleLoader.mjs", import.meta.url), {
|
||||
parentURL: import.meta.url,
|
||||
data: { mappings: bootstrap.config.moduleMappings },
|
||||
});
|
||||
const { startPluginRuntime } = await import("./runtimePeer.mjs");
|
||||
await startPluginRuntime({
|
||||
port: process.parentPort,
|
||||
config: bootstrap.config,
|
||||
loadPlugin: (entryUrl) => import(entryUrl),
|
||||
});
|
||||
process.parentPort.postMessage({ type: "netcatty-plugin:ready" });
|
||||
65
electron/plugins/runtime/viewPreload.cjs
Normal file
65
electron/plugins/runtime/viewPreload.cjs
Normal file
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
function subscribe(channel, listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
const handler = (_event, value) => listener(value);
|
||||
ipcRenderer.on(channel, handler);
|
||||
return Object.freeze({ dispose: () => ipcRenderer.removeListener(channel, handler) });
|
||||
}
|
||||
|
||||
const ENVIRONMENT_CHANNEL = "netcatty-plugin-view:environment";
|
||||
const environmentListeners = new Set();
|
||||
let latestEnvironment;
|
||||
let environmentGeneration = 0;
|
||||
let initialEnvironmentRequest;
|
||||
|
||||
function publishEnvironment(value) {
|
||||
latestEnvironment = value;
|
||||
environmentGeneration += 1;
|
||||
for (const listener of environmentListeners) listener(value);
|
||||
}
|
||||
|
||||
ipcRenderer.on(ENVIRONMENT_CHANNEL, (_event, value) => publishEnvironment(value));
|
||||
|
||||
function subscribeEnvironment(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
environmentListeners.add(listener);
|
||||
if (latestEnvironment !== undefined) {
|
||||
const value = latestEnvironment;
|
||||
const generation = environmentGeneration;
|
||||
queueMicrotask(() => {
|
||||
if (environmentListeners.has(listener) && generation === environmentGeneration) listener(value);
|
||||
});
|
||||
} else if (!initialEnvironmentRequest) {
|
||||
initialEnvironmentRequest = ipcRenderer.invoke("netcatty-plugin-view:get-environment")
|
||||
.then((value) => {
|
||||
if (latestEnvironment === undefined) publishEnvironment(value);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { initialEnvironmentRequest = undefined; });
|
||||
}
|
||||
return Object.freeze({ dispose: () => environmentListeners.delete(listener) });
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("netcattyView", Object.freeze({
|
||||
postMessage(message) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:post-message", message);
|
||||
},
|
||||
executeCommand(command, args) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:execute-command", { command, args });
|
||||
},
|
||||
getState() {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:get-state");
|
||||
},
|
||||
setState(state) {
|
||||
return ipcRenderer.invoke("netcatty-plugin-view:set-state", state);
|
||||
},
|
||||
onDidReceiveMessage(listener) {
|
||||
return subscribe("netcatty-plugin-view:message", listener);
|
||||
},
|
||||
onDidChangeEnvironment(listener) {
|
||||
return subscribeEnvironment(listener);
|
||||
},
|
||||
}));
|
||||
618
electron/plugins/runtimePeer.test.cjs
Normal file
618
electron/plugins/runtimePeer.test.cjs
Normal file
@@ -0,0 +1,618 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
|
||||
const { PluginRpcError, PluginRpcRouter, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
test("runtime peer exposes secure host capabilities over the canonical RPC transport", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const values = new Map();
|
||||
const calls = [];
|
||||
const deadlines = [];
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.example.peer",
|
||||
send(message) { port1.postMessage(message); },
|
||||
handlers: {
|
||||
"storage.get": async () => {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Invalid storage key");
|
||||
},
|
||||
"storage.set": async ({ key, value }) => { values.set(key, value); return null; },
|
||||
"secrets.set": async ({ key, value }) => {
|
||||
calls.push(["secret", key, value]);
|
||||
return {
|
||||
secret: { kind: "secret", id: "secret-reference-0000000000000000", key },
|
||||
};
|
||||
},
|
||||
"secrets.get": async () => ({
|
||||
found: true,
|
||||
secret: { kind: "secret", id: "secret-reference-0000000000000000", key: "api-key" },
|
||||
}),
|
||||
"credentials.createLease": async ({ operationId }) => ({
|
||||
kind: "secret-lease",
|
||||
id: "l".repeat(32),
|
||||
operationId,
|
||||
expiresAt: 10_000,
|
||||
}),
|
||||
"network.request": async ({ url, timeoutMs }, requestContext) => {
|
||||
deadlines.push(["network", requestContext.deadlineMs, timeoutMs]);
|
||||
return {
|
||||
url,
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: { encoding: "base64", data: "b2s=" },
|
||||
};
|
||||
},
|
||||
"filesystem.readFile": async ({ path }) => ({ data: `read:${path}` }),
|
||||
"filesystem.writeFile": async ({ path, data }) => { calls.push(["write", path, data]); return null; },
|
||||
"filesystem.stat": async () => ({ kind: "file", size: 2, modifiedAt: 1 }),
|
||||
"filesystem.readDirectory": async () => ({ entries: [{ name: "file", kind: "file" }] }),
|
||||
"companion.start": async () => ({ handleId: "companion-handle-000000000000" }),
|
||||
"companion.request": async ({ method, params, timeoutMs }, requestContext) => {
|
||||
deadlines.push(["companion", requestContext.deadlineMs, timeoutMs]);
|
||||
return { method, params };
|
||||
},
|
||||
"companion.stop": async ({ handleId }) => { calls.push(["stop", handleId]); return null; },
|
||||
"log.write": async () => null,
|
||||
},
|
||||
});
|
||||
let resolveUnhandledStreamCancellation;
|
||||
let rejectUnhandledStreamCancellation;
|
||||
const unhandledStreamCancellation = new Promise((resolve, reject) => {
|
||||
resolveUnhandledStreamCancellation = resolve;
|
||||
rejectUnhandledStreamCancellation = reject;
|
||||
});
|
||||
port1.on("message", (message) => {
|
||||
const accepted = host.accept(message);
|
||||
if (message?.frame?.streamId === "unhandled" && message.frame.kind === "cancel") {
|
||||
accepted.then(resolveUnhandledStreamCancellation, rejectUnhandledStreamCancellation);
|
||||
}
|
||||
});
|
||||
const lifecycle = [];
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: {
|
||||
pluginId: "com.example.peer",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
default: {
|
||||
async activate(context) {
|
||||
lifecycle.push("activate");
|
||||
await assert.rejects(
|
||||
context.storage.get("invalid"),
|
||||
(error) => error?.code === "invalid_argument"
|
||||
&& error?.message === "Invalid storage key",
|
||||
);
|
||||
await context.storage.set("answer", 42);
|
||||
const secret = await context.secrets.set("api-key", "value");
|
||||
const lease = await context.credentials.createLease(secret, {
|
||||
operationId: "network:login",
|
||||
purpose: "Authenticate",
|
||||
});
|
||||
assert.equal(lease.operationId, "network:login");
|
||||
const vaultLease = await context.credentials.createLease({
|
||||
kind: "credential",
|
||||
id: "vault-credential-1",
|
||||
}, {
|
||||
operationId: "connection:login",
|
||||
purpose: "Authenticate connection",
|
||||
});
|
||||
assert.equal(vaultLease.operationId, "connection:login");
|
||||
assert.equal((await context.network.request({
|
||||
url: "https://example.com",
|
||||
timeoutMs: 45_000,
|
||||
})).status, 200);
|
||||
assert.equal(await context.filesystem.readFile("/tmp/file"), "read:/tmp/file");
|
||||
await context.filesystem.writeFile("/tmp/file", "ok", { overwrite: true });
|
||||
assert.equal((await context.filesystem.stat("/tmp/file")).kind, "file");
|
||||
assert.equal((await context.filesystem.readDirectory("/tmp")).length, 1);
|
||||
const companion = await context.companions.start("com.example.peer.helper");
|
||||
assert.deepEqual(await companion.request("echo", { value: 1 }, { timeoutMs: 50_000 }), {
|
||||
method: "echo",
|
||||
params: { value: 1 },
|
||||
});
|
||||
await companion.stop();
|
||||
},
|
||||
async deactivate() { lifecycle.push("deactivate"); },
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
assert.equal(initialized.pluginId, "com.example.peer");
|
||||
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
|
||||
await unhandledStreamCancellation;
|
||||
await assert.rejects(unhandledStream.write(new Uint8Array([1])), /closed/);
|
||||
await assert.rejects(
|
||||
host.request("plugin.unknown", {}),
|
||||
(error) => error?.code === -32012 && error?.data?.pluginCode === "unsupported",
|
||||
);
|
||||
await host.request("plugin.activate", {});
|
||||
assert.equal(values.get("answer"), 42);
|
||||
assert.deepEqual(calls, [
|
||||
["secret", "api-key", "value"],
|
||||
["write", "/tmp/file", "ok"],
|
||||
["stop", "companion-handle-000000000000"],
|
||||
]);
|
||||
assert.deepEqual(deadlines, [
|
||||
["network", 45_000, 45_000],
|
||||
["companion", 50_000, 50_000],
|
||||
]);
|
||||
await host.request("plugin.deactivate", {});
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
assert.deepEqual(lifecycle, ["activate", "deactivate"]);
|
||||
});
|
||||
|
||||
test("runtime companion handles retry a failed stop request", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
let stopCalls = 0;
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.example.retry-stop",
|
||||
send(message) { port1.postMessage(message); },
|
||||
handlers: {
|
||||
"companion.start": async () => ({ handleId: "companion-handle-000000000001" }),
|
||||
"companion.stop": async () => {
|
||||
stopCalls += 1;
|
||||
if (stopCalls === 1) throw new Error("transient stop failure");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: {
|
||||
pluginId: "com.example.retry-stop",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
default: {
|
||||
async activate(context) {
|
||||
const companion = await context.companions.start("com.example.retry-stop.helper");
|
||||
await assert.rejects(companion.stop());
|
||||
await companion.stop();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
assert.equal(stopCalls, 2);
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("runtime peer exposes contribution APIs and routes host UI events", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const calls = [];
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.example.ui",
|
||||
send(message) { port1.postMessage(message); },
|
||||
handlers: {
|
||||
"settings.get": async ({ settingId }) => ({ found: true, value: `value:${settingId}` }),
|
||||
"settings.update": async ({ settingId, value }) => {
|
||||
calls.push(["setting", settingId, value]);
|
||||
return { restartRequired: false };
|
||||
},
|
||||
"commands.execute": async ({ command, args }) => {
|
||||
calls.push(["execute", command, args]);
|
||||
return "host-result";
|
||||
},
|
||||
"contextKeys.set": async ({ key, value }) => { calls.push(["context", key, value]); return null; },
|
||||
"views.getState": async ({ viewId, scopeId }) => ({ state: { viewId, scopeId } }),
|
||||
"views.setState": async ({ viewId, scopeId, state }) => {
|
||||
calls.push(["view-state", viewId, scopeId, state]);
|
||||
return null;
|
||||
},
|
||||
"views.postMessage": async ({ viewId, message }) => { calls.push(["view-message", viewId, message]); },
|
||||
},
|
||||
});
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const events = [];
|
||||
let pluginContext;
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: {
|
||||
pluginId: "com.example.ui",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
environment: {
|
||||
locale: "en-GB",
|
||||
theme: "light",
|
||||
reducedMotion: false,
|
||||
highContrast: true,
|
||||
themeTokens: { "--background": "initial" },
|
||||
},
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
default: {
|
||||
async activate(context) {
|
||||
pluginContext = context;
|
||||
assert.equal(context.environment.locale, "fr-FR");
|
||||
assert.equal(context.environment.theme, "dark");
|
||||
assert.equal(context.environment.highContrast, true);
|
||||
assert.deepEqual(context.environment.themeTokens, { "--background": "active" });
|
||||
assert.throws(() => {
|
||||
context.environment.themeTokens["--background"] = "mutated";
|
||||
}, /read only/u);
|
||||
context.settings.onDidChange((event) => events.push(["settings", event.settingId]));
|
||||
context.environment.onDidChange((event) => events.push([
|
||||
"environment",
|
||||
event.locale,
|
||||
event.theme,
|
||||
event.themeTokens["--background"],
|
||||
]));
|
||||
context.views.onDidReceiveMessage("com.example.ui.view", (message) => events.push(["view", message]));
|
||||
context.commands.registerCommand("com.example.ui.hello", async (args, invocation) => ({ args, source: invocation.source }));
|
||||
context.commands.registerCommand("com.example.ui.void", async () => undefined);
|
||||
const staleDisposable = context.commands.registerCommand("com.example.ui.replaceable", async () => "old");
|
||||
staleDisposable.dispose();
|
||||
context.commands.registerCommand("com.example.ui.replaceable", async () => "new");
|
||||
staleDisposable.dispose();
|
||||
assert.equal(await context.settings.get("com.example.ui.greeting"), "value:com.example.ui.greeting");
|
||||
assert.deepEqual(await context.settings.update("com.example.ui.greeting", "hello"), { restartRequired: false });
|
||||
assert.equal(await context.commands.executeCommand("com.example.ui.hello", { nested: true }), "host-result");
|
||||
await context.contextKeys.set("com.example.ui.ready", true);
|
||||
assert.deepEqual(await context.views.getState("com.example.ui.view", "window-1"), {
|
||||
viewId: "com.example.ui.view",
|
||||
scopeId: "window-1",
|
||||
});
|
||||
await context.views.setState("com.example.ui.view", "window-1", { selected: 2 });
|
||||
context.views.postMessage("com.example.ui.view", { ready: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
await host.request("plugin.activate", {
|
||||
environment: {
|
||||
locale: "fr-FR",
|
||||
theme: "dark",
|
||||
reducedMotion: true,
|
||||
highContrast: true,
|
||||
themeTokens: { "--background": "active" },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await host.request("plugin.command.execute", {
|
||||
command: "com.example.ui.hello",
|
||||
args: { name: "Catty" },
|
||||
invocation: { source: "palette" },
|
||||
}), { args: { name: "Catty" }, source: "palette" });
|
||||
assert.equal(await host.request("plugin.command.execute", {
|
||||
command: "com.example.ui.void",
|
||||
invocation: { source: "palette" },
|
||||
}), null);
|
||||
assert.equal(await host.request("plugin.command.execute", {
|
||||
command: "com.example.ui.replaceable",
|
||||
invocation: { source: "palette" },
|
||||
}), "new");
|
||||
|
||||
host.notify("plugin.settings.changed", { settingId: "com.example.ui.greeting", scope: "application", scopeId: "application", source: "host" });
|
||||
host.notify("plugin.environment.changed", {
|
||||
locale: "zh-CN",
|
||||
theme: "dark",
|
||||
reducedMotion: true,
|
||||
highContrast: false,
|
||||
themeTokens: { "--background": "updated" },
|
||||
});
|
||||
host.notify("plugin.view.message", { viewId: "com.example.ui.view", message: { ping: true } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(pluginContext.environment.locale, "zh-CN");
|
||||
assert.equal(pluginContext.environment.reducedMotion, true);
|
||||
assert.deepEqual(pluginContext.environment.themeTokens, { "--background": "updated" });
|
||||
assert.deepEqual(events, [
|
||||
["settings", "com.example.ui.greeting"],
|
||||
["environment", "zh-CN", "dark", "updated"],
|
||||
["view", { ping: true }],
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
["setting", "com.example.ui.greeting", "hello"],
|
||||
["execute", "com.example.ui.hello", { nested: true }],
|
||||
["context", "com.example.ui.ready", true],
|
||||
["view-state", "com.example.ui.view", "window-1", { selected: 2 }],
|
||||
["view-message", "com.example.ui.view", { ready: true }],
|
||||
]);
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("runtime peer registers provider handlers and routes immutable terminal lifecycle events", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.example.provider",
|
||||
send(message) { port1.postMessage(message); },
|
||||
});
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const invocations = [];
|
||||
const terminalEvents = [];
|
||||
let cancellationObservedResolve;
|
||||
const cancellationObserved = new Promise((resolve) => {
|
||||
cancellationObservedResolve = resolve;
|
||||
});
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: {
|
||||
pluginId: "com.example.provider",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
default: {
|
||||
async activate(context) {
|
||||
context.terminals.onDidChange((event) => terminalEvents.push(event));
|
||||
const staleDisposable = context.providers.register(
|
||||
"com.example.provider.completion",
|
||||
"terminal.completion",
|
||||
async () => "stale",
|
||||
);
|
||||
staleDisposable.dispose();
|
||||
context.providers.register(
|
||||
"com.example.provider.completion",
|
||||
"terminal.completion",
|
||||
async (invocation) => {
|
||||
invocations.push(invocation);
|
||||
if (invocation.operation === "wait") {
|
||||
await new Promise((resolve) => {
|
||||
invocation.cancellationToken.onCancellationRequested(() => {
|
||||
cancellationObservedResolve();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
return { items: [{ label: "status" }] };
|
||||
},
|
||||
);
|
||||
staleDisposable.dispose();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
|
||||
assert.deepEqual(await host.request("provider.invoke", {
|
||||
providerId: "com.example.provider.completion",
|
||||
kind: "terminal.completion",
|
||||
operation: "provide",
|
||||
requestId: "request-1",
|
||||
payload: { input: "sta" },
|
||||
deadlineMs: 1_000,
|
||||
}), {
|
||||
requestId: "request-1",
|
||||
status: "ok",
|
||||
result: { items: [{ label: "status" }] },
|
||||
});
|
||||
assert.equal(invocations.length, 1);
|
||||
assert.equal(invocations[0].providerId, "com.example.provider.completion");
|
||||
assert.equal(invocations[0].kind, "terminal.completion");
|
||||
assert.equal(invocations[0].operation, "provide");
|
||||
assert.equal(invocations[0].deadlineMs, 1_000);
|
||||
assert.equal(invocations[0].cancellationToken.isCancellationRequested, false);
|
||||
assert.equal(Object.isFrozen(invocations[0].payload), true);
|
||||
|
||||
await assert.rejects(
|
||||
host.request("provider.invoke", {
|
||||
providerId: "com.example.provider.completion",
|
||||
kind: "terminal.hover",
|
||||
operation: "provide",
|
||||
requestId: "request-kind-mismatch",
|
||||
}),
|
||||
(error) => error?.code === RPC_ERRORS.failedPrecondition,
|
||||
);
|
||||
|
||||
const event = {
|
||||
type: "cwdChanged",
|
||||
session: {
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
status: "connected",
|
||||
cwd: "/srv/app",
|
||||
},
|
||||
};
|
||||
host.notify("plugin.terminal.event", event);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(terminalEvents, [event]);
|
||||
assert.equal(Object.isFrozen(terminalEvents[0]), true);
|
||||
assert.equal(Object.isFrozen(terminalEvents[0].session), true);
|
||||
|
||||
const controller = new AbortController();
|
||||
const cancelledRequest = host.request("provider.invoke", {
|
||||
providerId: "com.example.provider.completion",
|
||||
kind: "terminal.completion",
|
||||
operation: "wait",
|
||||
requestId: "request-cancelled",
|
||||
}, { signal: controller.signal });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(cancelledRequest, (error) => error?.code === RPC_ERRORS.cancelled);
|
||||
await cancellationObserved;
|
||||
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("runtime peer routes connection and importer Provider operation maps", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.example.connection",
|
||||
send(message) { port1.postMessage(message); },
|
||||
});
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const invocations = [];
|
||||
let legacyError;
|
||||
let legacyImporterError;
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: {
|
||||
pluginId: "com.example.connection",
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
default: {
|
||||
async activate(context) {
|
||||
try {
|
||||
context.providers.register("com.example.connection.legacy", "connection", async () => null);
|
||||
} catch (error) {
|
||||
legacyError = { code: error?.code, message: error?.message };
|
||||
}
|
||||
context.providers.register("com.example.connection.transport", "connection", {
|
||||
validateConfiguration(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.configuration]);
|
||||
return { valid: true, issues: [] };
|
||||
},
|
||||
probe(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.configuration]);
|
||||
return { available: true };
|
||||
},
|
||||
open() {
|
||||
throw new Error("open is not invoked by this test");
|
||||
},
|
||||
resize(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.columns, invocation.payload.rows]);
|
||||
return null;
|
||||
},
|
||||
signal(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.signal]);
|
||||
return null;
|
||||
},
|
||||
reconnect(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.connectionId]);
|
||||
return null;
|
||||
},
|
||||
close(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.connectionId]);
|
||||
return null;
|
||||
},
|
||||
getStatus(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.connectionId]);
|
||||
return { status: "connected" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
context.providers.register("com.example.connection.legacyImporter", "importer", async () => null);
|
||||
} catch (error) {
|
||||
legacyImporterError = { code: error?.code, message: error?.message };
|
||||
}
|
||||
context.providers.register("com.example.connection.importer", "importer", {
|
||||
detect(invocation) {
|
||||
invocations.push([invocation.operation, invocation.payload.fileName]);
|
||||
return { confidence: 0.75, format: "example" };
|
||||
},
|
||||
parse() {
|
||||
throw new Error("parse is not invoked by this test");
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
|
||||
assert.deepEqual(legacyError, {
|
||||
code: "invalid_argument",
|
||||
message: "Connection Provider handler must be an operation map",
|
||||
});
|
||||
assert.deepEqual(legacyImporterError, {
|
||||
code: "invalid_argument",
|
||||
message: "Importer Provider handler must be an operation map",
|
||||
});
|
||||
assert.deepEqual(await host.request("provider.invoke", {
|
||||
providerId: "com.example.connection.transport",
|
||||
kind: "connection",
|
||||
operation: "resize",
|
||||
requestId: "connection-resize-1",
|
||||
payload: { connectionId: "connection-1", operationId: "operation-1", columns: 132, rows: 44 },
|
||||
deadlineMs: 1_000,
|
||||
}), {
|
||||
requestId: "connection-resize-1",
|
||||
status: "ok",
|
||||
result: null,
|
||||
});
|
||||
assert.deepEqual(await host.request("provider.invoke", {
|
||||
providerId: "com.example.connection.importer",
|
||||
kind: "importer",
|
||||
operation: "detect",
|
||||
requestId: "importer-detect-1",
|
||||
payload: {
|
||||
fileName: "hosts.json",
|
||||
sample: { encoding: "base64", data: "e30=" },
|
||||
},
|
||||
deadlineMs: 1_000,
|
||||
}), {
|
||||
requestId: "importer-detect-1",
|
||||
status: "ok",
|
||||
result: { confidence: 0.75, format: "example" },
|
||||
});
|
||||
assert.deepEqual(await host.request("provider.invoke", {
|
||||
providerId: "com.example.connection.transport",
|
||||
kind: "connection",
|
||||
operation: "getStatus",
|
||||
requestId: "connection-status-1",
|
||||
payload: { connectionId: "connection-1", operationId: "operation-2" },
|
||||
deadlineMs: 1_000,
|
||||
}), {
|
||||
requestId: "connection-status-1",
|
||||
status: "ok",
|
||||
result: { status: "connected" },
|
||||
});
|
||||
assert.deepEqual(invocations, [
|
||||
["resize", 132, 44],
|
||||
["detect", "hosts.json"],
|
||||
["getStatus", "connection-1"],
|
||||
]);
|
||||
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
711
electron/plugins/runtimeSupervisor.cjs
Normal file
711
electron/plugins/runtimeSupervisor.cjs
Normal file
@@ -0,0 +1,711 @@
|
||||
"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,
|
||||
};
|
||||
953
electron/plugins/runtimeSupervisor.test.cjs
Normal file
953
electron/plugins/runtimeSupervisor.test.cjs
Normal file
@@ -0,0 +1,953 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { RuntimeSupervisor } = require("./runtimeSupervisor.cjs");
|
||||
const { createContainmentError } = require("./utilityPluginRuntime.cjs");
|
||||
|
||||
function pluginManifest(overrides = {}) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.runtime-test",
|
||||
name: "runtime-test",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "dist/browser.js", node: "dist/node.js" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(context, runtimeFactory, supervisorOptions = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-supervisor-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const paths = createPluginPaths(root);
|
||||
fs.mkdirSync(paths.logs, { recursive: true });
|
||||
const database = new PluginDatabase(paths.database);
|
||||
context.after(() => {
|
||||
try { database.close(); } catch {}
|
||||
});
|
||||
const manifest = pluginManifest();
|
||||
database.installVersion({
|
||||
pluginId: manifest.id,
|
||||
version: manifest.version,
|
||||
manifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${manifest.id}/${manifest.version}/package`,
|
||||
}, { enable: true });
|
||||
const packageRoot = path.join(paths.packages, manifest.id, manifest.version, "package");
|
||||
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
||||
const runtimeOptions = [];
|
||||
const factory = (options) => {
|
||||
runtimeOptions.push(options);
|
||||
return runtimeFactory(options);
|
||||
};
|
||||
const supervisor = new RuntimeSupervisor({
|
||||
electron: {},
|
||||
database,
|
||||
packageStore: { async preparePackageRoot() { return packageRoot; } },
|
||||
protocol: {},
|
||||
paths,
|
||||
netcattyVersion: "0.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
runtimeDirectory: path.join(root, "runtime"),
|
||||
appRoot: process.cwd(),
|
||||
runtimeFactories: { browser: factory, utility: factory },
|
||||
...supervisorOptions,
|
||||
});
|
||||
return { database, manifest, runtimeOptions, supervisor };
|
||||
}
|
||||
|
||||
test("supervisor prefers the ordinary browser runtime and enforces negotiated identity", async (context) => {
|
||||
const calls = [];
|
||||
const initialEnvironment = { locale: "en", theme: "light", reducedMotion: false, highContrast: false };
|
||||
const activationEnvironment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: true };
|
||||
let environment = initialEnvironment;
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config, options) {
|
||||
calls.push(["start", config]);
|
||||
environment = activationEnvironment;
|
||||
assert.deepEqual(options.getActivationEnvironment(), activationEnvironment);
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() { calls.push(["stop"]); },
|
||||
}), {
|
||||
getInitialEnvironment(identity) {
|
||||
assert.equal(identity.pluginId, "com.example.runtime-test");
|
||||
assert.match(identity.runtimeId, /^[0-9a-f-]+$/u);
|
||||
return environment;
|
||||
},
|
||||
});
|
||||
|
||||
const started = await fixture.supervisor.start(fixture.manifest.id);
|
||||
assert.deepEqual(started, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id));
|
||||
assert.match(started.securityPrincipal, /^unsigned-package:/u);
|
||||
assert.equal(Object.hasOwn(started, "request"), false);
|
||||
assert.equal(Object.hasOwn(started, "stop"), false);
|
||||
assert.equal(fixture.runtimeOptions[0].plugin.manifest.main.browser, "dist/browser.js");
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.kind, "browser");
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "running");
|
||||
assert.deepEqual(calls[0][1].environment, initialEnvironment);
|
||||
assert.equal(Object.isFrozen(calls[0][1].environment), true);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
assert.deepEqual(calls.map(([kind]) => kind), ["start", "stop"]);
|
||||
});
|
||||
|
||||
test("supervisor verifies immutable package contents before runtime placement", async (context) => {
|
||||
let placementCalls = 0;
|
||||
const fixture = createFixture(context, () => {
|
||||
throw new Error("runtime factory must not run");
|
||||
}, {
|
||||
packageStore: {
|
||||
async preparePackageRoot() {
|
||||
throw new Error("installed package integrity check failed");
|
||||
},
|
||||
},
|
||||
resolveRuntimeKind() {
|
||||
placementCalls += 1;
|
||||
return "browser";
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
/installed package integrity check failed/,
|
||||
);
|
||||
assert.equal(placementCalls, 0);
|
||||
assert.deepEqual(fixture.runtimeOptions, []);
|
||||
});
|
||||
|
||||
test("supervisor attaches the runtime quota monitor before activation completes", async (context) => {
|
||||
const events = [];
|
||||
let releaseActivation;
|
||||
let signalProcessReady;
|
||||
const activation = new Promise((resolve) => { releaseActivation = resolve; });
|
||||
const processReady = new Promise((resolve) => { signalProcessReady = resolve; });
|
||||
const fixture = createFixture(context, (options) => ({
|
||||
async start(config) {
|
||||
events.push("start");
|
||||
options.onProcessReady();
|
||||
events.push("process-ready");
|
||||
signalProcessReady();
|
||||
await activation;
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
}), {
|
||||
runtimeResourceMonitor: {
|
||||
trackRuntime() {
|
||||
events.push("monitor");
|
||||
return { dispose() {} };
|
||||
},
|
||||
releaseRuntime() {},
|
||||
},
|
||||
});
|
||||
const starting = fixture.supervisor.start(fixture.manifest.id);
|
||||
await processReady;
|
||||
try {
|
||||
assert.deepEqual(events, ["start", "monitor", "process-ready"]);
|
||||
} finally {
|
||||
releaseActivation();
|
||||
}
|
||||
await starting;
|
||||
assert.equal(events.filter((event) => event === "monitor").length, 1);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
});
|
||||
|
||||
test("repeated activation failures quarantine after the third crash window event", async (context) => {
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start() { throw new Error("activation failed"); },
|
||||
async stop() {},
|
||||
}));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /activation failed/);
|
||||
}
|
||||
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.runtime.status, "quarantined");
|
||||
assert.ok(plugin.runtime.quarantinedAt != null);
|
||||
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /quarantined/);
|
||||
});
|
||||
|
||||
test("unexpected process exit is contained and recorded without touching other runtimes", async (context) => {
|
||||
let exit;
|
||||
const fixture = createFixture(context, (options) => {
|
||||
exit = options.onExit;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
exit({ expected: false, error: new Error("process crashed") });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.runtime.status, "error");
|
||||
assert.match(plugin.runtime.lastError, /process crashed/);
|
||||
});
|
||||
|
||||
test("a startup exit and its rejected start count as one crash", async (context) => {
|
||||
let attempts = 0;
|
||||
const fixture = createFixture(context, (options) => ({
|
||||
async start() {
|
||||
attempts += 1;
|
||||
options.onExit({ expected: false, error: new Error("startup process exit") });
|
||||
throw new Error("startup rejected");
|
||||
},
|
||||
async stop() {},
|
||||
}));
|
||||
|
||||
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
|
||||
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "error");
|
||||
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "quarantined");
|
||||
});
|
||||
|
||||
test("deactivation failure still leaves the runtime stopped", async (context) => {
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() { throw new Error("deactivation timed out"); },
|
||||
}));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.runtime.status, "stopped");
|
||||
assert.match(plugin.runtime.lastError, /deactivation timed out/);
|
||||
});
|
||||
|
||||
test("supervisor composes host routes with immutable activation identity for downstream capabilities", async (context) => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("settings.get", async (_params, identity) => ({
|
||||
manifestFrozen: Object.isFrozen(identity.manifest) && Object.isFrozen(identity.manifest.main),
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
}));
|
||||
let runtimeOptions;
|
||||
const fixture = createFixture(context, (options) => {
|
||||
runtimeOptions = options;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
}, { rpcRegistry: registry });
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const result = await runtimeOptions.requestHandlers["settings.get"]({}, {
|
||||
requestId: 1,
|
||||
signal: AbortSignal.timeout(100),
|
||||
});
|
||||
assert.equal(result.pluginId, fixture.manifest.id);
|
||||
assert.equal(result.manifestFrozen, true);
|
||||
assert.equal(result.pluginVersion, "1.0.0");
|
||||
assert.equal(typeof result.runtimeId, "string");
|
||||
assert.equal(result.runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
|
||||
});
|
||||
|
||||
test("supervisor binds the raw transport guard to the host-owned activation identity", async (context) => {
|
||||
const guarded = [];
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
}), {
|
||||
runtimeMessageGuard(identity, message) {
|
||||
guarded.push({ identity, message });
|
||||
},
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const message = { jsonrpc: "2.0", method: "$/progress" };
|
||||
fixture.runtimeOptions[0].onBeforeMessage(message);
|
||||
|
||||
assert.equal(guarded.length, 1);
|
||||
assert.equal(guarded[0].message, message);
|
||||
assert.equal(guarded[0].identity.pluginId, fixture.manifest.id);
|
||||
assert.equal(guarded[0].identity.runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
|
||||
assert.equal(Object.isFrozen(guarded[0].identity), true);
|
||||
assert.equal(Object.isFrozen(guarded[0].identity.manifest), true);
|
||||
});
|
||||
|
||||
test("supervisor exposes bounded host-to-plugin calls and rejects stale active versions", async (context) => {
|
||||
const calls = [];
|
||||
let runtimeOptions;
|
||||
const fixture = createFixture(context, (options) => {
|
||||
runtimeOptions = options;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
async request(method, params, requestOptions) {
|
||||
calls.push(["request", method, params, requestOptions]);
|
||||
return { status: "ok" };
|
||||
},
|
||||
notify(method, params) { calls.push(["notify", method, params]); },
|
||||
async openStream(streamId, windowBytes) {
|
||||
calls.push(["stream", streamId, windowBytes]);
|
||||
return { streamId };
|
||||
},
|
||||
};
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const currentIdentity = fixture.supervisor.getRuntimeIdentity(fixture.manifest.id);
|
||||
assert.deepEqual(
|
||||
await fixture.supervisor.request(fixture.manifest.id, "commands.execute", { command: "run" }),
|
||||
{ status: "ok" },
|
||||
);
|
||||
await fixture.supervisor.notify(fixture.manifest.id, "settings.changed", { key: "theme" });
|
||||
assert.deepEqual(
|
||||
await fixture.supervisor.openStream(fixture.manifest.id, "provider-output", 1024, {
|
||||
expectedIdentity: currentIdentity,
|
||||
}),
|
||||
{ streamId: "provider-output" },
|
||||
);
|
||||
assert.deepEqual(calls.map(([kind]) => kind), ["request", "notify", "stream"]);
|
||||
const staleIdentity = { ...currentIdentity, runtimeId: "runtime:stale" };
|
||||
await assert.rejects(
|
||||
fixture.supervisor.request(fixture.manifest.id, "provider.invoke", {}, {
|
||||
expectedIdentity: staleIdentity,
|
||||
}),
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.notify(fixture.manifest.id, "plugin.terminal.event", {}, {
|
||||
expectedIdentity: staleIdentity,
|
||||
}),
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.openStream(fixture.manifest.id, "stale-output", 1024, {
|
||||
expectedIdentity: staleIdentity,
|
||||
}),
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
assert.deepEqual(calls.map(([kind]) => kind), ["request", "notify", "stream"]);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.request(fixture.manifest.id, "plugin.activate", {}),
|
||||
/reserved/,
|
||||
);
|
||||
|
||||
const replacement = pluginManifest({ version: "2.0.0" });
|
||||
fixture.database.installVersion({
|
||||
pluginId: replacement.id,
|
||||
version: replacement.version,
|
||||
manifest: replacement,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.supervisor.request(fixture.manifest.id, "commands.execute", {}),
|
||||
/stale or inactive/,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtimeOptions.requestHandlers["storage.keys"]({}, { signal: AbortSignal.timeout(100) }),
|
||||
(error) => error?.code === -32014,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime placement and state events are injectable without changing lifecycle ownership", async (context) => {
|
||||
const events = [];
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
}), {
|
||||
resolveRuntimeKind({ availableKinds, plugin }) {
|
||||
assert.deepEqual(availableKinds, ["browser", "utility"]);
|
||||
assert.equal(Object.isFrozen(plugin), true);
|
||||
assert.equal(Object.isFrozen(plugin.manifest), true);
|
||||
return "utility";
|
||||
},
|
||||
});
|
||||
const disposable = fixture.supervisor.onDidChangeRuntime((event) => events.push(event));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
disposable.dispose();
|
||||
assert.deepEqual(events.map(({ status }) => status), ["starting", "running", "stopped"]);
|
||||
assert.equal(events[0].runtimeKind, "utility");
|
||||
assert.equal(events[0].runtimeId, events[2].runtimeId);
|
||||
});
|
||||
|
||||
test("supervisor transfers privileged terminal ports only to the exact utility runtime identity", async (context) => {
|
||||
const attached = [];
|
||||
let releaseAttachment;
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
attached.push({ descriptor, port });
|
||||
return new Promise((resolve) => { releaseAttachment = resolve; });
|
||||
},
|
||||
}), {
|
||||
resolveRuntimeKind: () => "utility",
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const identity = fixture.supervisor.getRuntimeIdentity(fixture.manifest.id);
|
||||
const port = { close() {} };
|
||||
let attachmentFinished = false;
|
||||
const attachment = fixture.supervisor.attachTerminalInterceptor(
|
||||
fixture.manifest.id,
|
||||
{ providerId: "com.example.input", direction: "input" },
|
||||
port,
|
||||
{ expectedIdentity: identity },
|
||||
).then(() => { attachmentFinished = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(attached[0].port, port);
|
||||
assert.equal(attachmentFinished, false);
|
||||
releaseAttachment();
|
||||
await attachment;
|
||||
assert.equal(attachmentFinished, true);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.attachTerminalInterceptor(
|
||||
fixture.manifest.id,
|
||||
{ providerId: "com.example.input", direction: "input" },
|
||||
port,
|
||||
{ expectedIdentity: { ...identity, runtimeId: "stale-runtime" } },
|
||||
),
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
});
|
||||
|
||||
test("progress events carry immutable activation identity for downstream provider correlation", async (context) => {
|
||||
let runtimeOptions;
|
||||
const fixture = createFixture(context, (options) => {
|
||||
runtimeOptions = options;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
});
|
||||
const events = [];
|
||||
const disposable = fixture.supervisor.onDidReportProgress((event) => events.push(event));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const value = { kind: "report", message: "Parsing", percentage: 25 };
|
||||
runtimeOptions.onProgress({ token: "import-1", value });
|
||||
value.message = "spoofed";
|
||||
disposable.dispose();
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
|
||||
assert.equal(events[0].token, "import-1");
|
||||
assert.deepEqual(events[0].value, { kind: "report", message: "Parsing", percentage: 25 });
|
||||
assert.equal(Object.isFrozen(events[0].value), true);
|
||||
});
|
||||
|
||||
test("host-to-plugin results are discarded when the active runtime changes in flight", async (context) => {
|
||||
let resolveRequest;
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
async request() {
|
||||
return new Promise((resolve) => { resolveRequest = resolve; });
|
||||
},
|
||||
}));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const pending = fixture.supervisor.request(fixture.manifest.id, "provider.invoke", {});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const replacement = pluginManifest({ version: "2.0.0" });
|
||||
fixture.database.installVersion({
|
||||
pluginId: replacement.id,
|
||||
version: replacement.version,
|
||||
manifest: replacement,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
|
||||
});
|
||||
resolveRequest({ status: "ok", stale: true });
|
||||
await assert.rejects(pending, (error) => error?.code === -32014);
|
||||
});
|
||||
|
||||
test("a host stream is cancelled when the active runtime changes while it opens", async (context) => {
|
||||
let resolveOpen;
|
||||
let cancelled = 0;
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
async openStream() {
|
||||
return new Promise((resolve) => { resolveOpen = resolve; });
|
||||
},
|
||||
}));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const pending = fixture.supervisor.openStream(fixture.manifest.id, "import-output", 1024);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const replacement = pluginManifest({ version: "2.0.0" });
|
||||
fixture.database.installVersion({
|
||||
pluginId: replacement.id,
|
||||
version: replacement.version,
|
||||
manifest: replacement,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
|
||||
});
|
||||
resolveOpen({ cancel() { cancelled += 1; } });
|
||||
await assert.rejects(pending, (error) => error?.code === -32014);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
|
||||
test("runtime placement cannot start a version replaced while policy is pending", async (context) => {
|
||||
let resolvePlacement;
|
||||
let factoryCalls = 0;
|
||||
let markPlacementEntered;
|
||||
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
|
||||
const fixture = createFixture(context, () => {
|
||||
factoryCalls += 1;
|
||||
return {};
|
||||
}, {
|
||||
resolveRuntimeKind: async () => {
|
||||
markPlacementEntered();
|
||||
return new Promise((resolve) => { resolvePlacement = resolve; });
|
||||
},
|
||||
});
|
||||
const pending = fixture.supervisor.start(fixture.manifest.id);
|
||||
await placementEntered;
|
||||
const replacement = pluginManifest({ version: "2.0.0" });
|
||||
fixture.database.installVersion({
|
||||
pluginId: replacement.id,
|
||||
version: replacement.version,
|
||||
manifest: replacement,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
|
||||
});
|
||||
resolvePlacement("browser");
|
||||
await assert.rejects(pending, (error) => error?.code === -32014);
|
||||
assert.equal(factoryCalls, 0);
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).activeVersion, "2.0.0");
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.lastError, null);
|
||||
});
|
||||
|
||||
test("stop cancels a pending runtime placement decision before a process is created", async (context) => {
|
||||
let placementSignal;
|
||||
let markPlacementEntered;
|
||||
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
|
||||
let factoryCalls = 0;
|
||||
const fixture = createFixture(context, () => {
|
||||
factoryCalls += 1;
|
||||
return {};
|
||||
}, {
|
||||
resolveRuntimeKind({ signal }) {
|
||||
placementSignal = signal;
|
||||
markPlacementEntered();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const pending = fixture.supervisor.start(fixture.manifest.id);
|
||||
await placementEntered;
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
await assert.rejects(pending, (error) => error?.code === -32001);
|
||||
assert.equal(placementSignal.aborted, true);
|
||||
assert.equal(factoryCalls, 0);
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
|
||||
});
|
||||
|
||||
test("stop cancels activation without recording a plugin crash", async (context) => {
|
||||
let rejectActivation;
|
||||
let markActivationEntered;
|
||||
const activationEntered = new Promise((resolve) => { markActivationEntered = resolve; });
|
||||
const fixture = createFixture(context, () => ({
|
||||
start() {
|
||||
markActivationEntered();
|
||||
return new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
},
|
||||
async stop() {
|
||||
rejectActivation(new Error("activation stopped"));
|
||||
},
|
||||
}));
|
||||
const pending = fixture.supervisor.start(fixture.manifest.id);
|
||||
await activationEntered;
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
await assert.rejects(pending, (error) => error?.code === -32001);
|
||||
const active = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(active.runtime.status, "stopped");
|
||||
assert.equal(active.runtime.lastError, null);
|
||||
assert.equal(Number(fixture.database.db.prepare(
|
||||
"SELECT COUNT(*) AS count FROM plugin_crashes WHERE plugin_id = ?",
|
||||
).get(fixture.manifest.id).count), 0);
|
||||
});
|
||||
|
||||
test("stop finishes even when plugin activation never settles", async (context) => {
|
||||
let markActivationEntered;
|
||||
const activationEntered = new Promise((resolve) => { markActivationEntered = resolve; });
|
||||
let stops = 0;
|
||||
const fixture = createFixture(context, () => ({
|
||||
start() {
|
||||
markActivationEntered();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
async stop() { stops += 1; },
|
||||
}));
|
||||
const pending = fixture.supervisor.start(fixture.manifest.id);
|
||||
await activationEntered;
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
await assert.rejects(pending, (error) => error?.code === -32001);
|
||||
assert.equal(stops, 1);
|
||||
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
|
||||
});
|
||||
|
||||
test("lazy activation waits until the previous runtime has fully stopped", async (context) => {
|
||||
let factoryCalls = 0;
|
||||
let releaseFirstStop;
|
||||
let markFirstStopEntered;
|
||||
const firstStopEntered = new Promise((resolve) => { markFirstStopEntered = resolve; });
|
||||
const fixture = createFixture(context, () => {
|
||||
const instance = factoryCalls;
|
||||
factoryCalls += 1;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
if (instance !== 0) return;
|
||||
markFirstStopEntered();
|
||||
await new Promise((resolve) => { releaseFirstStop = resolve; });
|
||||
},
|
||||
};
|
||||
});
|
||||
const firstIdentity = await fixture.supervisor.start(fixture.manifest.id);
|
||||
const stopping = fixture.supervisor.stop(fixture.manifest.id);
|
||||
await firstStopEntered;
|
||||
const restarting = fixture.supervisor.start(fixture.manifest.id);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(factoryCalls, 1);
|
||||
releaseFirstStop();
|
||||
await stopping;
|
||||
const secondIdentity = await restarting;
|
||||
assert.equal(factoryCalls, 2);
|
||||
assert.notEqual(secondIdentity.runtimeId, firstIdentity.runtimeId);
|
||||
});
|
||||
|
||||
test("runtime stop waits for companion cleanup before it resolves", async (context) => {
|
||||
const events = [];
|
||||
let markCleanupEntered;
|
||||
let releaseCleanup;
|
||||
const cleanupEntered = new Promise((resolve) => { markCleanupEntered = resolve; });
|
||||
const cleanupReleased = new Promise((resolve) => { releaseCleanup = resolve; });
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() { events.push("runtime-stop"); },
|
||||
}), {
|
||||
async runtimeCleanup(identity) {
|
||||
events.push(`cleanup:${identity.runtimeId}`);
|
||||
markCleanupEntered(identity);
|
||||
await cleanupReleased;
|
||||
},
|
||||
});
|
||||
const identity = await fixture.supervisor.start(fixture.manifest.id);
|
||||
let settled = false;
|
||||
const stopping = fixture.supervisor.stop(fixture.manifest.id).finally(() => { settled = true; });
|
||||
const cleanupIdentity = await cleanupEntered;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.deepEqual(cleanupIdentity, identity);
|
||||
assert.deepEqual(events, ["runtime-stop", `cleanup:${identity.runtimeId}`]);
|
||||
releaseCleanup();
|
||||
await stopping;
|
||||
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
|
||||
});
|
||||
|
||||
test("companion cleanup containment failure is persisted and blocks restart", async (context) => {
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
}), {
|
||||
async runtimeCleanup() { throw new Error("companion process tree survived"); },
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
|
||||
await assert.rejects(
|
||||
fixture.supervisor.stop(fixture.manifest.id),
|
||||
/companion process tree survived/,
|
||||
);
|
||||
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.enabled, false);
|
||||
assert.equal(plugin.runtime.status, "quarantined");
|
||||
assert.match(plugin.runtime.lastError, /companion process tree survived/);
|
||||
assert.notEqual(plugin.runtime.quarantinedAt, null);
|
||||
fixture.database.setEnabled(fixture.manifest.id, true);
|
||||
fixture.database.clearQuarantine(fixture.manifest.id);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
/restart Netcatty before starting it again/,
|
||||
);
|
||||
});
|
||||
|
||||
test("containment failure disables the plugin and blocks replacement activation until restart", async (context) => {
|
||||
let factoryCalls = 0;
|
||||
const fixture = createFixture(context, () => {
|
||||
factoryCalls += 1;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
throw createContainmentError("advanced process was not reaped");
|
||||
},
|
||||
};
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
|
||||
await assert.rejects(
|
||||
fixture.supervisor.stop(fixture.manifest.id),
|
||||
/advanced process was not reaped/,
|
||||
);
|
||||
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.enabled, false);
|
||||
assert.equal(plugin.runtime.status, "quarantined");
|
||||
assert.notEqual(plugin.runtime.quarantinedAt, null);
|
||||
fixture.database.setEnabled(fixture.manifest.id, true);
|
||||
fixture.database.clearQuarantine(fixture.manifest.id);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
/restart Netcatty before starting it again/,
|
||||
);
|
||||
assert.equal(factoryCalls, 1);
|
||||
const stillQuarantined = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(stillQuarantined.enabled, false);
|
||||
assert.equal(stillQuarantined.runtime.status, "quarantined");
|
||||
assert.notEqual(stillQuarantined.runtime.quarantinedAt, null);
|
||||
});
|
||||
|
||||
test("startup cleanup containment failure blocks replacement activation", async (context) => {
|
||||
let factoryCalls = 0;
|
||||
const fixture = createFixture(context, () => {
|
||||
factoryCalls += 1;
|
||||
return {
|
||||
async start() {
|
||||
throw new Error("activation failed");
|
||||
},
|
||||
async stop() {
|
||||
throw createContainmentError("failed startup process was not reaped");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
/failed startup process was not reaped/,
|
||||
);
|
||||
let plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.enabled, false);
|
||||
assert.equal(plugin.runtime.status, "quarantined");
|
||||
assert.match(plugin.runtime.lastError, /failed startup process was not reaped/);
|
||||
assert.notEqual(plugin.runtime.quarantinedAt, null);
|
||||
|
||||
fixture.database.setEnabled(fixture.manifest.id, true);
|
||||
fixture.database.clearQuarantine(fixture.manifest.id);
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
/restart Netcatty before starting it again/,
|
||||
);
|
||||
assert.equal(factoryCalls, 1);
|
||||
plugin = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(plugin.enabled, false);
|
||||
assert.equal(plugin.runtime.status, "quarantined");
|
||||
assert.notEqual(plugin.runtime.quarantinedAt, null);
|
||||
});
|
||||
|
||||
test("shutdown cancels unresolved placement and waits for startup cleanup", async (context) => {
|
||||
let signal;
|
||||
let markPlacementEntered;
|
||||
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
|
||||
const fixture = createFixture(context, () => {
|
||||
throw new Error("runtime must not be created after shutdown");
|
||||
}, {
|
||||
resolveRuntimeKind(contextValue) {
|
||||
signal = contextValue.signal;
|
||||
markPlacementEntered();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const pending = fixture.supervisor.start(fixture.manifest.id);
|
||||
await placementEntered;
|
||||
await fixture.supervisor.shutdown();
|
||||
await assert.rejects(pending, (error) => error?.code === -32014);
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
|
||||
});
|
||||
|
||||
test("concurrent supervisor shutdown callers share complete runtime teardown", async (context) => {
|
||||
let releaseStop;
|
||||
let stopCalls = 0;
|
||||
const stopReleased = new Promise((resolve) => { releaseStop = resolve; });
|
||||
const fixture = createFixture(context, () => ({
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
stopCalls += 1;
|
||||
await stopReleased;
|
||||
},
|
||||
}));
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
|
||||
const first = fixture.supervisor.shutdown();
|
||||
const second = fixture.supervisor.shutdown();
|
||||
assert.equal(first, second);
|
||||
let secondSettled = false;
|
||||
void second.finally(() => { secondSettled = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(secondSettled, false);
|
||||
|
||||
releaseStop();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
|
||||
});
|
||||
|
||||
test("a stale runtime crash cannot quarantine or overwrite a replacement version", async (context) => {
|
||||
let exit;
|
||||
const fixture = createFixture(context, (options) => {
|
||||
exit = options.onExit;
|
||||
return {
|
||||
async start(config) {
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: config.enabledFeatures,
|
||||
};
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
});
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
const replacement = pluginManifest({ version: "2.0.0" });
|
||||
fixture.database.installVersion({
|
||||
pluginId: replacement.id,
|
||||
version: replacement.version,
|
||||
manifest: replacement,
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.supervisor.start(fixture.manifest.id),
|
||||
(error) => error?.code === -32014,
|
||||
);
|
||||
exit({ expected: false, error: new Error("stale process crashed") });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const active = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(active.activeVersion, "2.0.0");
|
||||
assert.equal(active.runtime.lastError, null);
|
||||
assert.equal(active.runtime.quarantinedAt, null);
|
||||
fixture.database.installVersion({
|
||||
pluginId: fixture.manifest.id,
|
||||
version: fixture.manifest.version,
|
||||
manifest: fixture.manifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${fixture.manifest.id}/${fixture.manifest.version}/package`,
|
||||
});
|
||||
const priorVersion = fixture.database.getActivePlugin(fixture.manifest.id);
|
||||
assert.equal(priorVersion.runtime.status, "error");
|
||||
assert.match(priorVersion.runtime.lastError, /stale process crashed/);
|
||||
});
|
||||
162
electron/plugins/secretLease.cjs
Normal file
162
electron/plugins/secretLease.cjs
Normal file
@@ -0,0 +1,162 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes } = require("node:crypto");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const DEFAULT_SECRET_LEASE_TTL_MS = 30_000;
|
||||
const MAX_SECRET_LEASE_TTL_MS = 60_000;
|
||||
const MAX_SECRET_LEASES_PER_RUNTIME = 128;
|
||||
|
||||
function assertLeaseReference(lease) {
|
||||
if (
|
||||
!lease
|
||||
|| typeof lease !== "object"
|
||||
|| Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string"
|
||||
|| lease.id.length < 32
|
||||
|| lease.id.length > 256
|
||||
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Secret lease reference is invalid");
|
||||
return lease.id;
|
||||
}
|
||||
|
||||
class SecretLeaseStore {
|
||||
constructor(options) {
|
||||
this.secretStore = options.secretStore;
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
this.randomBytes = options.randomBytes ?? randomBytes;
|
||||
this.setTimer = options.setTimeout ?? setTimeout;
|
||||
this.clearTimer = options.clearTimeout ?? clearTimeout;
|
||||
this.leases = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
issue(options) {
|
||||
if (this.closed) throw new PluginRpcError(RPC_ERRORS.unavailable, "Secret lease broker is closed");
|
||||
if (options.signal?.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Secret lease operation was cancelled");
|
||||
}
|
||||
const activeCount = [...this.leases.values()].filter((lease) => (
|
||||
lease.runtimeId === options.runtimeId
|
||||
)).length;
|
||||
if (activeCount >= MAX_SECRET_LEASES_PER_RUNTIME) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many active secret leases");
|
||||
}
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_SECRET_LEASE_TTL_MS;
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > MAX_SECRET_LEASE_TTL_MS) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Secret lease lifetime is invalid");
|
||||
}
|
||||
const id = this.randomBytes(24).toString("base64url");
|
||||
if (options.resolveSecret !== undefined && typeof options.resolveSecret !== "function") {
|
||||
throw new TypeError("Secret lease resolver must be a function");
|
||||
}
|
||||
const credential = options.credential ?? options.secret;
|
||||
if (!credential) throw new TypeError("Secret lease credential is required");
|
||||
const expiresAt = this.clock() + ttlMs;
|
||||
const record = {
|
||||
id,
|
||||
pluginId: options.pluginId,
|
||||
runtimeId: options.runtimeId,
|
||||
credential,
|
||||
resolveSecret: options.resolveSecret ?? null,
|
||||
operationId: options.operationId,
|
||||
purpose: options.purpose,
|
||||
expiresAt,
|
||||
timer: null,
|
||||
abortCleanup: null,
|
||||
};
|
||||
record.timer = this.setTimer(() => this.#delete(id), ttlMs);
|
||||
record.timer?.unref?.();
|
||||
if (options.signal) {
|
||||
const onAbort = () => this.#delete(id);
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
record.abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
this.leases.set(id, record);
|
||||
if (options.signal?.aborted) {
|
||||
this.#delete(id);
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Secret lease operation was cancelled");
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "secret-lease",
|
||||
id,
|
||||
operationId: options.operationId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
consume(options) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Secret lease operation was cancelled");
|
||||
}
|
||||
const id = assertLeaseReference(options.lease);
|
||||
const record = this.leases.get(id);
|
||||
if (!record || record.expiresAt <= this.clock()) {
|
||||
this.#delete(id);
|
||||
throw new PluginRpcError(RPC_ERRORS.notFound, "Secret lease is missing or expired");
|
||||
}
|
||||
if (
|
||||
record.pluginId !== options.pluginId
|
||||
|| record.runtimeId !== options.runtimeId
|
||||
|| record.operationId !== options.operationId
|
||||
) throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Secret lease scope does not match the operation");
|
||||
this.#delete(id);
|
||||
const consumeContext = Object.freeze({
|
||||
pluginId: record.pluginId,
|
||||
runtimeId: record.runtimeId,
|
||||
operationId: record.operationId,
|
||||
credential: record.credential,
|
||||
signal: options.signal,
|
||||
});
|
||||
const resolved = record.resolveSecret
|
||||
? record.resolveSecret(consumeContext)
|
||||
: this.secretStore.resolve(record.pluginId, record.credential);
|
||||
if (!resolved || typeof resolved.then !== "function") {
|
||||
if (options.signal?.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Secret lease operation was cancelled");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
return Promise.resolve(resolved).then((value) => {
|
||||
if (options.signal?.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Secret lease operation was cancelled");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
#delete(id) {
|
||||
const record = this.leases.get(id);
|
||||
if (!record) return false;
|
||||
this.leases.delete(id);
|
||||
this.clearTimer(record.timer);
|
||||
record.abortCleanup?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
revokeRuntime(runtimeId) {
|
||||
for (const [id, lease] of [...this.leases]) {
|
||||
if (lease.runtimeId === runtimeId) this.#delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
revokeOperation(pluginId, operationId) {
|
||||
for (const [id, lease] of [...this.leases]) {
|
||||
if (lease.pluginId === pluginId && lease.operationId === operationId) this.#delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const id of [...this.leases.keys()]) this.#delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SECRET_LEASE_TTL_MS,
|
||||
MAX_SECRET_LEASES_PER_RUNTIME,
|
||||
MAX_SECRET_LEASE_TTL_MS,
|
||||
SecretLeaseStore,
|
||||
assertLeaseReference,
|
||||
};
|
||||
348
electron/plugins/secretStore.cjs
Normal file
348
electron/plugins/secretStore.cjs
Normal file
@@ -0,0 +1,348 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes } = require("node:crypto");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_SECRET_BYTES = 64 * 1024;
|
||||
|
||||
function assertSecretKey(key) {
|
||||
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret key is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function assertSecretRef(secret) {
|
||||
if (
|
||||
!secret
|
||||
|| typeof secret !== "object"
|
||||
|| Array.isArray(secret)
|
||||
|| secret.kind !== "secret"
|
||||
|| typeof secret.id !== "string"
|
||||
|| secret.id.length < 16
|
||||
|| secret.id.length > 256
|
||||
|| typeof secret.key !== "string"
|
||||
|| secret.key.length < 1
|
||||
|| secret.key.length > 256
|
||||
|| secret.key.includes("\0")
|
||||
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret reference is invalid");
|
||||
return { id: secret.id, key: secret.key };
|
||||
}
|
||||
|
||||
class PluginSecretStore {
|
||||
/** @type {Map<string, { value: string, secretRef: string }>} */
|
||||
#overwriteStash = new Map();
|
||||
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.safeStorage = options.safeStorage ?? null;
|
||||
this.randomBytes = options.randomBytes ?? randomBytes;
|
||||
}
|
||||
|
||||
#stashKey(pluginId, key) {
|
||||
return `${pluginId}\0${key}`;
|
||||
}
|
||||
|
||||
#assertAvailable() {
|
||||
const backend = this.safeStorage?.getSelectedStorageBackend?.();
|
||||
if (
|
||||
!this.safeStorage?.isEncryptionAvailable?.()
|
||||
|| backend === "basic_text"
|
||||
|| typeof this.safeStorage.encryptString !== "function"
|
||||
|| typeof this.safeStorage.decryptString !== "function"
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Secure OS-backed encryption is unavailable for plugin secrets",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReference(pluginId, key) {
|
||||
assertSecretKey(key);
|
||||
const record = this.database.getSecretByKey(pluginId, key);
|
||||
return record ? Object.freeze({ kind: "secret", id: record.secretRef, key: record.key }) : undefined;
|
||||
}
|
||||
|
||||
getRecordByReference(pluginId, secret) {
|
||||
const reference = assertSecretRef(secret);
|
||||
const record = this.database.getSecretByRef(pluginId, reference.id);
|
||||
if (!record || record.key !== reference.key) {
|
||||
throw new PluginRpcError(RPC_ERRORS.notFound, "Plugin secret reference was not found");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
set(pluginId, key, value, options = {}) {
|
||||
this.#assertAvailable();
|
||||
assertSecretKey(key);
|
||||
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret value is invalid or too large");
|
||||
}
|
||||
const stashPrevious = options.stashPrevious === true;
|
||||
const stashKey = this.#stashKey(pluginId, key);
|
||||
let stashed = false;
|
||||
if (stashPrevious && !this.#overwriteStash.has(stashKey)) {
|
||||
// Keep an existing stash (e.g. after a failed restore) so a later retry
|
||||
// still recovers the original SecretRef, not a rejected replacement.
|
||||
const existing = this.getReference(pluginId, key);
|
||||
if (existing) {
|
||||
try {
|
||||
this.#overwriteStash.set(stashKey, {
|
||||
value: this.resolve(pluginId, existing),
|
||||
// Keep the prior SecretRef id so saved provider connections still resolve.
|
||||
secretRef: existing.id,
|
||||
});
|
||||
stashed = true;
|
||||
} catch {
|
||||
/* keep going; restore may be unavailable for this key */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const secretRef = this.randomBytes(24).toString("base64url");
|
||||
const ciphertext = this.safeStorage.encryptString(value);
|
||||
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
|
||||
}
|
||||
this.database.upsertSecret({ pluginId, key, secretRef, ciphertext });
|
||||
return Object.freeze({ kind: "secret", id: secretRef, key });
|
||||
} catch (error) {
|
||||
// Failed replacement must not leave prior plaintext stranded in memory.
|
||||
if (stashed) this.#overwriteStash.delete(stashKey);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the plaintext (and SecretRef id) stashed by the last overwrite.
|
||||
* Returns true when a stashed value was written back.
|
||||
*/
|
||||
restoreOverwrite(pluginId, key) {
|
||||
this.#assertAvailable();
|
||||
assertSecretKey(key);
|
||||
const stashKey = this.#stashKey(pluginId, key);
|
||||
const previous = this.#overwriteStash.get(stashKey);
|
||||
if (!previous || typeof previous.value !== "string" || typeof previous.secretRef !== "string") {
|
||||
return false;
|
||||
}
|
||||
const ciphertext = this.safeStorage.encryptString(previous.value);
|
||||
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
|
||||
}
|
||||
this.database.upsertSecret({
|
||||
pluginId,
|
||||
key,
|
||||
secretRef: previous.secretRef,
|
||||
ciphertext,
|
||||
});
|
||||
// Drop the stash only after the prior value is durably written so a failed
|
||||
// encrypt/upsert can still be retried.
|
||||
this.#overwriteStash.delete(stashKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearOverwriteStash(pluginId, key) {
|
||||
assertSecretKey(key);
|
||||
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
|
||||
}
|
||||
|
||||
delete(pluginId, key) {
|
||||
assertSecretKey(key);
|
||||
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
|
||||
this.database.deleteSecret(pluginId, key);
|
||||
}
|
||||
|
||||
deleteByKeyPrefix(pluginId, prefix) {
|
||||
assertSecretKey(prefix);
|
||||
let deleted = 0;
|
||||
if (typeof this.database.deleteSecretsByKeyPrefix !== "function") {
|
||||
this.delete(pluginId, prefix);
|
||||
deleted = 1;
|
||||
} else {
|
||||
deleted = this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
|
||||
}
|
||||
const pluginPrefix = `${pluginId}\0`;
|
||||
for (const stashKey of [...this.#overwriteStash.keys()]) {
|
||||
if (!stashKey.startsWith(pluginPrefix)) continue;
|
||||
const secretKey = stashKey.slice(pluginPrefix.length);
|
||||
if (secretKey === prefix || secretKey.startsWith(`${prefix}:`)) {
|
||||
this.#overwriteStash.delete(stashKey);
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable providerId → pluginId binding so disconnect can wipe sync secrets
|
||||
* after the contribution disappears (disabled/uninstalled plugin).
|
||||
* Stored in a host-owned table, not plugin-writable secrets.
|
||||
* Overwrites any prior empty-plugin_id unbind tombstone.
|
||||
*/
|
||||
bindSyncProviderPlugin(pluginId, providerId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) {
|
||||
throw new TypeError("Plugin id is invalid");
|
||||
}
|
||||
assertSecretKey(providerId);
|
||||
// Contribution provider ids live under the owning plugin namespace
|
||||
// (`pluginId.`…); they are never equal to the plugin id itself.
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
throw new TypeError("Sync provider id is outside the plugin namespace");
|
||||
}
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") {
|
||||
throw new Error("Plugin sync provider binding storage is unavailable");
|
||||
}
|
||||
this.database.upsertSyncProviderBinding(providerId, pluginId);
|
||||
}
|
||||
|
||||
resolveSyncProviderPlugin(providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.getSyncProviderBinding === "function") {
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (
|
||||
typeof pluginId === "string"
|
||||
&& pluginId.length > 0
|
||||
&& providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
// Do not infer ownership from sync-credential* key prefixes: a parent
|
||||
// plugin id may namespace-prefix another plugin's provider and would cause
|
||||
// disconnect to delete the wrong secrets. Pre-binding installs recover via
|
||||
// (1) backfillSyncProviderBindingsFromLegacySecrets for intermediate map
|
||||
// rows, (2) backfillSyncProviderBindingsFromLiveProviders using host
|
||||
// contribution metadata at startup, or (3) the next successful put.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed missing provider→plugin bindings from contribution metadata.
|
||||
*
|
||||
* Real schema-2 installs only stored plugin-scoped `sync-credential*` rows
|
||||
* (no per-provider ownership). After upgrade, seed a binding only when a
|
||||
* plugin with credentials has exactly one sync provider id across the
|
||||
* catalog: binding every historical/active provider would let disconnecting
|
||||
* a stale provider wipe the shared credential prefix used by the live one.
|
||||
* Multi-provider plugins wait for the next successful put (or map backfill).
|
||||
*
|
||||
* @param {Array<{ pluginId?: string, id?: string, provider?: { id?: string } }>} providers
|
||||
* @returns {number} number of newly written bindings
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLiveProviders(providers) {
|
||||
if (!Array.isArray(providers)) return 0;
|
||||
if (typeof this.database.listPluginIdsWithSecretKeyPrefix !== "function") return 0;
|
||||
const credentialOwners = new Set(
|
||||
this.database.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
if (credentialOwners.size === 0) return 0;
|
||||
/** @type {Map<string, Set<string>>} pluginId -> provider ids it declares */
|
||||
const providersByPlugin = new Map();
|
||||
/** @type {Map<string, Set<string>>} providerId -> plugins claiming it */
|
||||
const pluginsByProvider = new Map();
|
||||
for (const entry of providers) {
|
||||
const pluginId = entry?.pluginId;
|
||||
const providerId = entry?.provider?.id ?? entry?.id;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) continue;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) continue;
|
||||
// Count every manifest claim for conflict detection — including live
|
||||
// plugins that have not stored credentials yet. Binding only a
|
||||
// credential-backed legacy parent would orphan credentials when a later
|
||||
// disconnect prefers the live nested owner (Codex P2 on 8d64ea20).
|
||||
const claimants = pluginsByProvider.get(providerId) || new Set();
|
||||
claimants.add(pluginId);
|
||||
pluginsByProvider.set(providerId, claimants);
|
||||
// Only credential-backed plugins are eligible to become the binding owner.
|
||||
if (!credentialOwners.has(pluginId)) continue;
|
||||
const set = providersByPlugin.get(pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByPlugin.set(pluginId, set);
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [pluginId, providerIds] of providersByPlugin) {
|
||||
if (providerIds.size !== 1) continue;
|
||||
const providerId = [...providerIds][0];
|
||||
// Two credential-backed plugins can both declare the same provider via
|
||||
// nested namespaces (legacy disabled parent + live child). Binding the
|
||||
// first writer would make a later missing-provider disconnect delete the
|
||||
// wrong sync-credential* rows. Skip any cross-plugin claim conflict.
|
||||
const claimants = pluginsByProvider.get(providerId);
|
||||
if (!claimants || claimants.size !== 1) continue;
|
||||
// Skip active binds and explicit-unbind tombstones alike.
|
||||
if (typeof this.database.getSyncProviderBinding === "function"
|
||||
&& this.database.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.bindSyncProviderPlugin(pluginId, providerId);
|
||||
promoted += 1;
|
||||
} catch {
|
||||
/* invalid contribution ids stay unbound */
|
||||
}
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
unbindSyncProviderPlugin(pluginId, providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") return;
|
||||
const existing = typeof this.database.getSyncProviderBinding === "function"
|
||||
? this.database.getSyncProviderBinding(providerId)
|
||||
: null;
|
||||
// Do not steal another plugin's active binding. Empty plugin_id is an
|
||||
// unbind tombstone and may be refreshed idempotently.
|
||||
if (
|
||||
existing
|
||||
&& typeof existing.pluginId === "string"
|
||||
&& existing.pluginId.length > 0
|
||||
&& existing.pluginId !== pluginId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Consume leftover intermediate-build map markers so they cannot re-seed
|
||||
// a binding if the tombstone is ever cleared. Delete under this plugin and
|
||||
// any residual map rows for the same provider across plugins.
|
||||
const mapKey = `sync-provider-map:${providerId}`;
|
||||
if (typeof this.database.deleteSecret === "function") {
|
||||
try {
|
||||
this.database.deleteSecret(pluginId, mapKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof this.database.findSecretsByKey === "function" && typeof this.database.deleteSecret === "function") {
|
||||
for (const row of this.database.findSecretsByKey(mapKey) || []) {
|
||||
if (typeof row?.pluginId !== "string" || typeof row?.key !== "string") continue;
|
||||
try {
|
||||
this.database.deleteSecret(row.pluginId, row.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tombstone with empty plugin_id so leftover sync-provider-map:* secret
|
||||
// rows cannot re-promote this provider on the next database open.
|
||||
this.database.upsertSyncProviderBinding(providerId, "");
|
||||
}
|
||||
|
||||
resolve(pluginId, secret) {
|
||||
this.#assertAvailable();
|
||||
const record = this.getRecordByReference(pluginId, secret);
|
||||
try {
|
||||
return this.safeStorage.decryptString(record.ciphertext);
|
||||
} catch {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin secret could not be decrypted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SECRET_BYTES,
|
||||
PluginSecretStore,
|
||||
assertSecretKey,
|
||||
assertSecretRef,
|
||||
};
|
||||
573
electron/plugins/secretStore.test.cjs
Normal file
573
electron/plugins/secretStore.test.cjs
Normal file
@@ -0,0 +1,573 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginCredentialBroker } = require("./credentialBroker.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
const { PluginSecretStore } = require("./secretStore.cjs");
|
||||
|
||||
function createDatabase(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-secrets-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
}
|
||||
|
||||
function fakeSafeStorage() {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`),
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("plugin secrets are OS-encrypted, opaque, ownership-bound, and uninstall-independent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let random = 0;
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const secret = store.set("com.example.one", "api-key", "plaintext-token");
|
||||
const record = database.getSecretByKey("com.example.one", "api-key");
|
||||
assert.equal(record.ciphertext.includes(Buffer.from("plaintext-token")), false);
|
||||
assert.deepEqual(store.getReference("com.example.one", "api-key"), secret);
|
||||
assert.equal(store.resolve("com.example.one", secret), "plaintext-token");
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.two", secret),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.one", { ...secret, key: "different-key" }),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
store.delete("com.example.one", "api-key");
|
||||
assert.equal(store.getReference("com.example.one", "api-key"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("plugin secrets fail closed when OS encryption is unavailable or ciphertext is corrupt", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const unavailable = new PluginSecretStore({ database, safeStorage: null });
|
||||
assert.throws(
|
||||
() => unavailable.set("com.example.one", "api-key", "value"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
const insecureBackend = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: {
|
||||
...fakeSafeStorage(),
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => insecureBackend.set("com.example.one", "api-key", "value"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
|
||||
const safeStorage = fakeSafeStorage();
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const secret = store.set("com.example.one", "api-key", "value");
|
||||
safeStorage.decryptString = () => { throw new Error("corrupt"); };
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.one", secret),
|
||||
(error) => error.code === RPC_ERRORS.dataLoss,
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("credential leases are one-use and bound to plugin, runtime, operation, abort, and expiry", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let now = 1_000;
|
||||
let random = 0;
|
||||
const secretStore = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const secret = secretStore.set("com.example.one", "api-key", "credential-value");
|
||||
const leaseStore = new SecretLeaseStore({
|
||||
secretStore,
|
||||
clock: () => now,
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
setTimeout: () => ({ timer: true }),
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
const broker = new PluginCredentialBroker({ secretStore, leaseStore });
|
||||
const runtime = {
|
||||
pluginId: "com.example.one",
|
||||
runtimeId: "runtime-1",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
const lease = leaseStore.issue({
|
||||
...runtime,
|
||||
secret,
|
||||
operationId: "network:login",
|
||||
purpose: "Authenticate",
|
||||
ttlMs: 500,
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.consumeLease({ ...runtime, runtimeId: "runtime-2" }, lease, "network:login"),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(await broker.consumeLease(runtime, lease, "network:login"), "credential-value");
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, lease, "network:login"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
|
||||
const expired = leaseStore.issue({
|
||||
...runtime,
|
||||
secret,
|
||||
operationId: "network:expired",
|
||||
purpose: "Expire",
|
||||
ttlMs: 1,
|
||||
});
|
||||
now += 2;
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, expired, "network:expired"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted = leaseStore.issue({
|
||||
...runtime,
|
||||
signal: controller.signal,
|
||||
secret,
|
||||
operationId: "network:aborted",
|
||||
purpose: "Abort",
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, aborted, "network:aborted"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("Netcatty credentials resolve only when a one-use provider lease is consumed", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const secretStore = new PluginSecretStore({ database, safeStorage: fakeSafeStorage() });
|
||||
const leaseStore = new SecretLeaseStore({ secretStore });
|
||||
let resolved = 0;
|
||||
let referenceChecks = 0;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore,
|
||||
leaseStore,
|
||||
credentialResolver: {
|
||||
assertReference: async (reference, leaseContext) => {
|
||||
referenceChecks += 1;
|
||||
assert.equal(reference.id, "vault-credential-1");
|
||||
assert.equal(leaseContext.operationId, "connection:open-1");
|
||||
},
|
||||
resolve: async (reference, consumeContext) => {
|
||||
resolved += 1;
|
||||
assert.equal(reference.id, "vault-credential-1");
|
||||
assert.equal(consumeContext.pluginId, "com.example.provider");
|
||||
assert.equal(consumeContext.purpose, "Authenticate connection");
|
||||
return "host-owned-plaintext";
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
const params = {
|
||||
secret: { kind: "credential", id: "vault-credential-1" },
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
assert.deepEqual(broker.describeAuthorization(params).resources, ["credential:vault-credential-1"]);
|
||||
assert.equal(referenceChecks, 0);
|
||||
const lease = await broker.createLease(params, runtime);
|
||||
assert.equal(referenceChecks, 1);
|
||||
assert.equal(resolved, 0);
|
||||
assert.equal(await broker.consumeLease(runtime, lease, "connection:open-1"), "host-owned-plaintext");
|
||||
assert.equal(resolved, 1);
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, lease, "connection:open-1"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("credential authorization descriptors do not probe opaque references before permission", async () => {
|
||||
let credentialChecks = 0;
|
||||
let secretChecks = 0;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore: {
|
||||
getRecordByReference() {
|
||||
secretChecks += 1;
|
||||
throw new Error("unknown plugin secret");
|
||||
},
|
||||
},
|
||||
leaseStore: { issue: () => { throw new Error("lease must not be issued"); } },
|
||||
credentialResolver: {
|
||||
async assertReference() {
|
||||
credentialChecks += 1;
|
||||
throw new Error("unknown Netcatty credential");
|
||||
},
|
||||
async resolve() { throw new Error("credential must not resolve"); },
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
assert.deepEqual(broker.describeAuthorization({
|
||||
...base,
|
||||
secret: { kind: "credential", id: "unknown-credential" },
|
||||
}).resources, ["credential:unknown-credential"]);
|
||||
assert.deepEqual(broker.describeAuthorization({
|
||||
...base,
|
||||
secret: { kind: "secret", id: "unknown-secret-reference", key: "api-key" },
|
||||
}).resources, ["secret:api-key"]);
|
||||
assert.equal(credentialChecks, 0);
|
||||
assert.equal(secretChecks, 0);
|
||||
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
await assert.rejects(broker.createLease({
|
||||
...base,
|
||||
secret: { kind: "credential", id: "unknown-credential" },
|
||||
}, runtime), /unknown Netcatty credential/);
|
||||
assert.equal(credentialChecks, 1);
|
||||
await assert.rejects(broker.createLease({
|
||||
...base,
|
||||
secret: { kind: "secret", id: "unknown-secret-reference", key: "api-key" },
|
||||
}, runtime), /unknown plugin secret/);
|
||||
assert.equal(secretChecks, 1);
|
||||
});
|
||||
|
||||
test("credential lease resolution cannot return plaintext after operation cancellation", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const secretStore = new PluginSecretStore({ database, safeStorage: fakeSafeStorage() });
|
||||
const leaseStore = new SecretLeaseStore({ secretStore });
|
||||
const controller = new AbortController();
|
||||
let releaseResolution;
|
||||
let resolutionSignal;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore,
|
||||
leaseStore,
|
||||
credentialResolver: {
|
||||
assertReference: async () => {},
|
||||
resolve: async (_reference, consumeContext) => {
|
||||
resolutionSignal = consumeContext.signal;
|
||||
await new Promise((resolve) => { releaseResolution = resolve; });
|
||||
return "must-not-escape-after-cancel";
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: controller.signal,
|
||||
assertActive: async () => controller.signal.throwIfAborted(),
|
||||
};
|
||||
const params = {
|
||||
secret: { kind: "credential", id: "vault-credential-1" },
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
const lease = await broker.createLease(params, runtime);
|
||||
const consuming = broker.consumeLease(runtime, lease, "connection:open-1");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resolutionSignal, controller.signal);
|
||||
controller.abort();
|
||||
releaseResolution();
|
||||
await assert.rejects(
|
||||
consuming,
|
||||
(error) => error.code === RPC_ERRORS.cancelled,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("overwrite stash restores previous plaintext and discard clears it", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let random = 0;
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const first = store.set("com.example.one", "sync-credential", "good-password");
|
||||
assert.equal(store.resolve("com.example.one", first), "good-password");
|
||||
const second = store.set("com.example.one", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
assert.equal(store.resolve("com.example.one", second), "bad-password");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), true);
|
||||
const restored = store.getReference("com.example.one", "sync-credential");
|
||||
assert.equal(restored.id, first.id, "restore must keep the prior SecretRef id");
|
||||
assert.equal(store.resolve("com.example.one", first), "good-password");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
|
||||
|
||||
store.set("com.example.one", "sync-credential", "another-bad", { stashPrevious: true });
|
||||
store.clearOverwriteStash("com.example.one", "sync-credential");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
|
||||
assert.equal(
|
||||
store.resolve("com.example.one", store.getReference("com.example.one", "sync-credential")),
|
||||
"another-bad",
|
||||
);
|
||||
// Ordinary secrets.set-style overwrites must not stash plaintext by default.
|
||||
store.set("com.example.one", "api-key", "one");
|
||||
store.set("com.example.one", "api-key", "two");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "api-key"), false);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("failed overwrite clears stashed plaintext", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
store.set("com.example", "sync-credential", "good-password");
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true }),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
|
||||
failEncrypt = false;
|
||||
const ref = store.getReference("com.example", "sync-credential");
|
||||
assert.equal(store.resolve("com.example", ref), "good-password");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("sync provider bindings live outside plugin secrets and reject namespace mismatches", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.throws(
|
||||
() => store.bindSyncProviderPlugin("com.example", "com.other.sync"),
|
||||
/outside the plugin namespace/,
|
||||
);
|
||||
store.set("com.attacker", "sync-provider-map:com.example.sync", "com.attacker");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Tombstone remains so legacy map backfill cannot resurrect after disconnect.
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
// Unbind also consumes map markers (including attacker leftovers).
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.attacker", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Reconnect clears the tombstone.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("unbind deletes owner map markers and blocks legacy re-promotion", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Credential evidence is required for map promote; simulate a real owner plus
|
||||
// an intermediate-build map row that backfill would otherwise promote.
|
||||
store.set("com.example", "sync-credential", "secret");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promote consumes the map marker",
|
||||
);
|
||||
// User disconnects; reintroduce a map row as if something wrote it back.
|
||||
// Tombstone still blocks re-promotion even with credentials + map present.
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("failed restoreOverwrite keeps stash for retry", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const first = store.set("com.example", "sync-credential", "good-password");
|
||||
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.restoreOverwrite("com.example", "sync-credential"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
failEncrypt = false;
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
|
||||
assert.equal(store.resolve("com.example", first), "good-password");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("existing overwrite stash is preserved across retry puts and cleared by prefix delete", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const first = store.set("com.example", "sync-credential", "good-password");
|
||||
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.restoreOverwrite("com.example", "sync-credential"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
failEncrypt = false;
|
||||
// Retry put must not replace the original good-password stash with bad-password.
|
||||
store.set("com.example", "sync-credential", "worse-password", { stashPrevious: true });
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
|
||||
assert.equal(store.resolve("com.example", first), "good-password");
|
||||
|
||||
store.set("com.example", "sync-credential", "temp", { stashPrevious: true });
|
||||
store.deleteByKeyPrefix("com.example", "sync-credential");
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
|
||||
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("resolveSyncProviderPlugin does not infer ownership from credential prefixes alone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Without a binding (or live-provider backfill), credentials alone are not enough.
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Explicit bind still works for disconnect cleanup.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill seeds bindings for v2 credential-only upgrades", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Real schema-2 path: credentials exist, binding table empty, no map rows.
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.set("com.disabled", "sync-credential", "pw2");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Host should pass installed manifests including disabled plugins.
|
||||
const promoted = store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
{ pluginId: "com.disabled", provider: { id: "com.disabled.sync" } },
|
||||
// No credentials under this plugin — skip.
|
||||
{ pluginId: "com.other", provider: { id: "com.other.cloud" } },
|
||||
// Wrong namespace alone would be multi with sync below — still skip evil.
|
||||
]);
|
||||
assert.equal(promoted, 2);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.disabled.sync"), "com.disabled");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.other.cloud"), undefined);
|
||||
// Idempotent when binding already exists.
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
// Multi-provider plugins must not all bind from one shared credential row.
|
||||
store.set("com.multi", "sync-credential", "shared");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.old" } },
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.new" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.old"), undefined);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.new"), undefined);
|
||||
// Cross-plugin claim on the same providerId must not bind the first writer.
|
||||
store.set("com.example.sync", "sync-credential", "nested");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync.foo" } },
|
||||
{ pluginId: "com.example.sync", provider: { id: "com.example.sync.foo" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync.foo"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill does not resurrect explicit unbind tombstones", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
187
electron/plugins/secureCapabilities.cjs
Normal file
187
electron/plugins/secureCapabilities.cjs
Normal file
@@ -0,0 +1,187 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { assertSecretKey } = require("./secretStore.cjs");
|
||||
|
||||
function assertSecretParams(params, options = {}) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret parameters are invalid");
|
||||
}
|
||||
const key = assertSecretKey(params.key);
|
||||
if (options.value) {
|
||||
if (typeof params.value !== "string") {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret value is invalid");
|
||||
}
|
||||
return { key, value: params.value };
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
function secretAuthorization(params, action) {
|
||||
const value = assertSecretParams(params);
|
||||
return {
|
||||
permission: "secrets",
|
||||
resources: [`secret:${value.key}`],
|
||||
reason: `${action} plugin secret ${value.key}`,
|
||||
operationId: `secret:${action.toLowerCase()}:${value.key}`,
|
||||
};
|
||||
}
|
||||
|
||||
function registerSecurePluginCapabilities(registry, options) {
|
||||
registry.use(options.quotaManager.createMiddleware());
|
||||
registry.use(options.permissionEngine.createMiddleware());
|
||||
|
||||
registry.registerRequest("secrets.get", async ({ key }, context) => {
|
||||
const secret = options.secretStore.getReference(context.pluginId, key);
|
||||
return secret ? { found: true, secret } : { found: false };
|
||||
}, {
|
||||
metadata: { capability: "secrets", mutating: false, permission: "secrets" },
|
||||
validateParams: (params) => assertSecretParams(params),
|
||||
authorization: (params) => secretAuthorization(params, "Read"),
|
||||
});
|
||||
registry.registerRequest("secrets.set", async ({ key, value }, context) => {
|
||||
await context.assertActive();
|
||||
return { secret: options.secretStore.set(context.pluginId, key, value) };
|
||||
}, {
|
||||
metadata: { capability: "secrets", mutating: true, permission: "secrets" },
|
||||
validateParams: (params) => assertSecretParams(params, { value: true }),
|
||||
authorization: (params) => secretAuthorization(params, "Store"),
|
||||
});
|
||||
registry.registerRequest("secrets.delete", async ({ key }, context) => {
|
||||
await context.assertActive();
|
||||
options.secretStore.delete(context.pluginId, key);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "secrets", mutating: true, permission: "secrets" },
|
||||
validateParams: (params) => assertSecretParams(params),
|
||||
authorization: (params) => secretAuthorization(params, "Delete"),
|
||||
});
|
||||
|
||||
registry.registerRequest("credentials.createLease", (params, context) => (
|
||||
options.credentialBroker.createLease(params, context)
|
||||
), {
|
||||
metadata: { capability: "credentials", mutating: true, permission: "secrets" },
|
||||
validateParams: options.assertLeaseParams,
|
||||
authorization: (params) => options.credentialBroker.describeAuthorization(params),
|
||||
});
|
||||
|
||||
registry.registerRequest("network.request", async (params, context) => {
|
||||
// params are already registry-validated (preserve credentialLease/authorization).
|
||||
const validated = params;
|
||||
const lease = validated?.credentialLease;
|
||||
if (lease === undefined) {
|
||||
return options.networkBroker.request(validated, context);
|
||||
}
|
||||
if (!options.credentialBroker || typeof options.credentialBroker.consumeLease !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin credential leases are unavailable");
|
||||
}
|
||||
// Bind consumption to the host-owned network operation id (network:<origin>),
|
||||
// not the plugin-echoed lease.operationId, so leases cannot be replayed across ops.
|
||||
const hostOperationId = options.networkBroker.describeAuthorization(validated).operationId;
|
||||
const plaintext = await options.credentialBroker.consumeLease(
|
||||
context,
|
||||
lease,
|
||||
hostOperationId,
|
||||
);
|
||||
if (typeof plaintext !== "string" || plaintext.length < 1) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin credential lease resolved to an empty secret");
|
||||
}
|
||||
const authorization = validated?.authorization && typeof validated.authorization === "object"
|
||||
&& !Array.isArray(validated.authorization)
|
||||
? validated.authorization
|
||||
: { scheme: "Bearer" };
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
const headers = { ...(validated.headers ?? {}) };
|
||||
if (scheme === "Basic") {
|
||||
const username = typeof authorization.username === "string" ? authorization.username : "";
|
||||
headers.authorization = `Basic ${Buffer.from(`${username}:${plaintext}`, "utf8").toString("base64")}`;
|
||||
} else {
|
||||
headers.authorization = `Bearer ${plaintext}`;
|
||||
}
|
||||
const { credentialLease: _lease, authorization: _auth, ...requestParams } = validated;
|
||||
return options.networkBroker.request({
|
||||
...requestParams,
|
||||
headers,
|
||||
}, context);
|
||||
}, {
|
||||
metadata: { capability: "network", mutating: true, permission: "network" },
|
||||
validateParams: (params) => options.networkBroker.validate(params),
|
||||
authorization: (params) => options.networkBroker.describeAuthorization(params),
|
||||
});
|
||||
|
||||
registry.registerRequest("filesystem.readFile", (params, context) => (
|
||||
options.filesystemBroker.readFile(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validateRead(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "exact"),
|
||||
});
|
||||
registry.registerRequest("filesystem.stat", (params, context) => (
|
||||
options.filesystemBroker.stat(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validatePath(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "exact"),
|
||||
});
|
||||
registry.registerRequest("filesystem.readDirectory", (params, context) => (
|
||||
options.filesystemBroker.readDirectory(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validatePath(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
|
||||
});
|
||||
registry.registerRequest("filesystem.writeFile", (params, context) => (
|
||||
options.filesystemBroker.writeFile(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: true, permission: "filesystem.write" },
|
||||
validateParams: (params) => options.filesystemBroker.validateWrite(params),
|
||||
authorization: (params) => options.filesystemBroker.describeWriteAuthorization(params),
|
||||
});
|
||||
|
||||
registry.registerRequest("companion.start", (params, context) => (
|
||||
options.companionSupervisor.start(params, context)
|
||||
), {
|
||||
metadata: { capability: "companion", mutating: true, permission: "companion.execute" },
|
||||
validateParams: (params) => options.companionSupervisor.validateStart(params),
|
||||
authorization: (params, context) => (
|
||||
options.companionSupervisor.describeStartAuthorization(params, context)
|
||||
),
|
||||
});
|
||||
registry.registerRequest("companion.request", async (params, context) => {
|
||||
const validated = options.companionSupervisor.validateRequest(params);
|
||||
if (validated.credentialLeases === undefined) {
|
||||
return options.companionSupervisor.request(validated, context);
|
||||
}
|
||||
const credentials = {};
|
||||
for (const [name, lease] of Object.entries(validated.credentialLeases)) {
|
||||
credentials[name] = await options.credentialBroker.consumeLease(
|
||||
context,
|
||||
lease,
|
||||
validated.operationId,
|
||||
);
|
||||
}
|
||||
await context.assertActive();
|
||||
return options.companionSupervisor.request({
|
||||
handleId: validated.handleId,
|
||||
method: validated.method,
|
||||
params: Object.freeze({
|
||||
payload: validated.params ?? null,
|
||||
credentials: Object.freeze(credentials),
|
||||
}),
|
||||
timeoutMs: validated.timeoutMs,
|
||||
}, context);
|
||||
}, {
|
||||
metadata: { capability: "companion", mutating: true, permission: "companion.execute" },
|
||||
validateParams: (params) => options.companionSupervisor.validateRequest(params),
|
||||
authorization: (params, context) => options.companionSupervisor.describeHandleAuthorization(params, context),
|
||||
});
|
||||
registry.registerRequest("companion.stop", (params, context) => (
|
||||
options.companionSupervisor.stop(params, context)
|
||||
), {
|
||||
metadata: { capability: "companion", mutating: true, permission: "companion.execute" },
|
||||
validateParams: (params) => options.companionSupervisor.validateStop(params),
|
||||
authorization: (params, context) => options.companionSupervisor.describeHandleAuthorization(params, context),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { assertSecretParams, registerSecurePluginCapabilities, secretAuthorization };
|
||||
336
electron/plugins/secureCapabilities.test.cjs
Normal file
336
electron/plugins/secureCapabilities.test.cjs
Normal file
@@ -0,0 +1,336 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
|
||||
|
||||
function createRegistrations(secretStore) {
|
||||
const requests = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler) { requests.set(method, handler); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore,
|
||||
credentialBroker: broker,
|
||||
networkBroker: broker,
|
||||
filesystemBroker: broker,
|
||||
companionSupervisor: broker,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
test("secret mutations recheck runtime activity immediately before commit", async () => {
|
||||
const events = [];
|
||||
const requests = createRegistrations({
|
||||
set(_pluginId, _key, _value) {
|
||||
events.push("set");
|
||||
return { kind: "secret", id: "secret-reference-0000000000000000", key: "api-key" };
|
||||
},
|
||||
delete() { events.push("delete"); },
|
||||
getReference() { return null; },
|
||||
});
|
||||
const activeContext = {
|
||||
pluginId: "com.example.secure",
|
||||
async assertActive() { events.push("active"); },
|
||||
};
|
||||
await requests.get("secrets.set")({ key: "api-key", value: "value" }, activeContext);
|
||||
await requests.get("secrets.delete")({ key: "api-key" }, activeContext);
|
||||
assert.deepEqual(events, ["active", "set", "active", "delete"]);
|
||||
|
||||
const stoppedContext = {
|
||||
pluginId: "com.example.secure",
|
||||
async assertActive() { throw new Error("runtime stopped"); },
|
||||
};
|
||||
await assert.rejects(
|
||||
requests.get("secrets.set")({ key: "api-key", value: "value" }, stoppedContext),
|
||||
/runtime stopped/,
|
||||
);
|
||||
await assert.rejects(
|
||||
requests.get("secrets.delete")({ key: "api-key" }, stoppedContext),
|
||||
/runtime stopped/,
|
||||
);
|
||||
assert.deepEqual(events, ["active", "set", "active", "delete"]);
|
||||
});
|
||||
|
||||
test("filesystem RPC authorization fixes resource kind by operation without host I/O", () => {
|
||||
const registrations = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const filesystemCalls = [];
|
||||
const filesystemBroker = {
|
||||
validateRead: (params) => params,
|
||||
validateWrite: (params) => params,
|
||||
validatePath: (params) => params,
|
||||
describeReadAuthorization(params, resourceKind) {
|
||||
filesystemCalls.push([params.path, resourceKind]);
|
||||
return { permission: "filesystem.read", resources: [params.path], resourceKinds: [resourceKind] };
|
||||
},
|
||||
describeWriteAuthorization(params) {
|
||||
filesystemCalls.push([params.path, "exact"]);
|
||||
return { permission: "filesystem.write", resources: [params.path], resourceKinds: ["exact"] };
|
||||
},
|
||||
readFile: async () => null,
|
||||
stat: async () => null,
|
||||
readDirectory: async () => null,
|
||||
writeFile: async () => null,
|
||||
};
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore: {},
|
||||
credentialBroker: broker,
|
||||
networkBroker: broker,
|
||||
filesystemBroker,
|
||||
companionSupervisor: broker,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
|
||||
const target = "/canonical/target";
|
||||
for (const method of ["filesystem.readFile", "filesystem.stat", "filesystem.readDirectory"]) {
|
||||
registrations.get(method).options.authorization({ path: target });
|
||||
}
|
||||
registrations.get("filesystem.writeFile").options.authorization({
|
||||
path: target,
|
||||
data: "value",
|
||||
overwrite: true,
|
||||
});
|
||||
assert.deepEqual(filesystemCalls, [
|
||||
[target, "exact"],
|
||||
[target, "exact"],
|
||||
[target, "directory"],
|
||||
[target, "exact"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("companion start authorization receives host-owned runtime placement", () => {
|
||||
const registrations = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
let observedContext;
|
||||
const companionSupervisor = {
|
||||
validateStart: (params) => params,
|
||||
describeStartAuthorization(params, context) {
|
||||
observedContext = context;
|
||||
return {
|
||||
permission: "companion.execute",
|
||||
resources: [params.companionId],
|
||||
};
|
||||
},
|
||||
validateRequest: (params) => params,
|
||||
validateStop: (params) => params,
|
||||
describeHandleAuthorization: () => ({
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.secure.helper"],
|
||||
}),
|
||||
start: async () => null,
|
||||
request: async () => null,
|
||||
stop: async () => null,
|
||||
};
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore: {},
|
||||
credentialBroker: broker,
|
||||
networkBroker: broker,
|
||||
filesystemBroker: broker,
|
||||
companionSupervisor,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
const runtimeContext = { runtimeKind: "utility", runtimeId: "runtime-advanced" };
|
||||
registrations.get("companion.start").options.authorization({
|
||||
companionId: "com.example.secure.helper",
|
||||
}, runtimeContext);
|
||||
assert.equal(observedContext, runtimeContext);
|
||||
});
|
||||
|
||||
test("companion requests consume operation-bound credential leases exactly once", async () => {
|
||||
const registrations = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const consumed = [];
|
||||
const forwarded = [];
|
||||
const credentialBroker = {
|
||||
async consumeLease(context, lease, operationId) {
|
||||
consumed.push({ context, lease, operationId });
|
||||
return lease.id.endsWith("user") ? "alice" : "correct horse battery staple";
|
||||
},
|
||||
};
|
||||
const companionSupervisor = {
|
||||
validateRequest(params) { return params; },
|
||||
describeHandleAuthorization: () => ({
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.secure.helper"],
|
||||
}),
|
||||
async request(params, context) {
|
||||
forwarded.push({ params, context });
|
||||
return { accepted: true };
|
||||
},
|
||||
validateStart: (params) => params,
|
||||
validateStop: (params) => params,
|
||||
describeStartAuthorization: () => ({
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.secure.helper"],
|
||||
}),
|
||||
start: async () => null,
|
||||
stop: async () => null,
|
||||
};
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore: {},
|
||||
credentialBroker,
|
||||
networkBroker: broker,
|
||||
filesystemBroker: broker,
|
||||
companionSupervisor,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
|
||||
const context = {
|
||||
pluginId: "com.example.secure",
|
||||
async assertActive() {},
|
||||
};
|
||||
const result = await registrations.get("companion.request").handler({
|
||||
handleId: "companion-handle-0001",
|
||||
method: "authenticate",
|
||||
params: { host: "example.test" },
|
||||
credentialLeases: {
|
||||
username: { kind: "secret-lease", id: "credential-lease-user" },
|
||||
password: { kind: "secret-lease", id: "credential-lease-password" },
|
||||
},
|
||||
operationId: "login",
|
||||
timeoutMs: 5_000,
|
||||
}, context);
|
||||
|
||||
assert.deepEqual(result, { accepted: true });
|
||||
assert.equal(consumed.length, 2);
|
||||
assert.deepEqual(consumed.map(({ lease, operationId }) => [lease.id, operationId]), [
|
||||
["credential-lease-user", "login"],
|
||||
["credential-lease-password", "login"],
|
||||
]);
|
||||
assert.equal(forwarded.length, 1);
|
||||
assert.deepEqual(forwarded[0].params, {
|
||||
handleId: "companion-handle-0001",
|
||||
method: "authenticate",
|
||||
params: {
|
||||
payload: { host: "example.test" },
|
||||
credentials: {
|
||||
username: "alice",
|
||||
password: "correct horse battery staple",
|
||||
},
|
||||
},
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(forwarded[0].context, context);
|
||||
});
|
||||
|
||||
test("network.request injects Authorization from a consumed credential lease", async () => {
|
||||
const registrations = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const forwarded = [];
|
||||
const consumed = [];
|
||||
const credentialBroker = {
|
||||
async consumeLease(context, lease, operationId) {
|
||||
consumed.push({ lease, operationId, pluginId: context.pluginId });
|
||||
return "lease-token-value";
|
||||
},
|
||||
};
|
||||
const networkBroker = {
|
||||
// Mirror production validate: preserve credentialLease / authorization.
|
||||
validate: (params) => ({
|
||||
url: params.url,
|
||||
method: params.method ?? "GET",
|
||||
headers: params.headers ?? {},
|
||||
timeoutMs: params.timeoutMs ?? 30_000,
|
||||
...(params.credentialLease === undefined ? {} : { credentialLease: params.credentialLease }),
|
||||
...(params.authorization === undefined ? {} : { authorization: params.authorization }),
|
||||
}),
|
||||
describeAuthorization: () => ({
|
||||
permission: "network",
|
||||
resources: ["https://example.com"],
|
||||
reason: "test",
|
||||
operationId: "network:https://example.com",
|
||||
}),
|
||||
async request(params, context) {
|
||||
forwarded.push({ params, context });
|
||||
return { status: 204, headers: {}, body: { encoding: "base64", data: "" } };
|
||||
},
|
||||
};
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore: {},
|
||||
credentialBroker,
|
||||
networkBroker,
|
||||
filesystemBroker: broker,
|
||||
companionSupervisor: broker,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
|
||||
const context = {
|
||||
pluginId: "com.example.secure",
|
||||
async assertActive() {},
|
||||
};
|
||||
const registration = registrations.get("network.request");
|
||||
// Simulate hostRpcRegistry: validateParams first, then handler(validatedParams).
|
||||
const validated = registration.options.validateParams({
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
authorization: { scheme: "Bearer" },
|
||||
});
|
||||
await registration.handler(validated, context);
|
||||
|
||||
assert.equal(consumed.length, 1);
|
||||
assert.equal(consumed[0].operationId, "network:https://example.com");
|
||||
assert.equal(forwarded[0].params.headers.authorization, "Bearer lease-token-value");
|
||||
assert.equal(forwarded[0].params.credentialLease, undefined);
|
||||
});
|
||||
|
||||
test("networkBroker.validate preserves credentialLease for registry dispatch", () => {
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async () => ({ status: 204, headers: new Headers(), body: null, url: "https://example.com/" }),
|
||||
permissionEngine: { check: () => {} },
|
||||
});
|
||||
const validated = broker.validate({
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
authorization: { scheme: "Basic", username: "alice" },
|
||||
});
|
||||
assert.equal(validated.credentialLease.kind, "secret-lease");
|
||||
assert.equal(validated.authorization.scheme, "Basic");
|
||||
assert.equal(validated.authorization.username, "alice");
|
||||
});
|
||||
54
electron/plugins/shutdownCoordinator.cjs
Normal file
54
electron/plugins/shutdownCoordinator.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
PLUGIN_DEACTIVATION_TIMEOUT_MS,
|
||||
PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS,
|
||||
PLUGIN_UTILITY_TERMINATION_GRACE_MS,
|
||||
} = require("./constants.cjs");
|
||||
|
||||
const shutdownHandlers = new Set();
|
||||
let shutdownPromise = null;
|
||||
|
||||
function registerPluginShutdown(handler) {
|
||||
if (typeof handler !== "function") throw new TypeError("Plugin shutdown handler must be a function");
|
||||
shutdownHandlers.add(handler);
|
||||
shutdownPromise = null;
|
||||
return () => {
|
||||
shutdownHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
function runPluginShutdown(options = {}) {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
if (shutdownHandlers.size === 0) return Promise.resolve({ timedOut: false });
|
||||
const timeoutMs = options.timeoutMs
|
||||
?? PLUGIN_DEACTIVATION_TIMEOUT_MS
|
||||
+ PLUGIN_UTILITY_TERMINATION_GRACE_MS
|
||||
+ PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS
|
||||
+ 500;
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
||||
});
|
||||
shutdownPromise = Promise.race([
|
||||
Promise.allSettled([...shutdownHandlers].map((handler) => Promise.resolve().then(handler)))
|
||||
.then((results) => {
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed) throw failed.reason;
|
||||
return { timedOut: false };
|
||||
}),
|
||||
timeout,
|
||||
]).finally(() => clearTimeout(timer));
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
function resetPluginShutdownForTests() {
|
||||
shutdownHandlers.clear();
|
||||
shutdownPromise = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerPluginShutdown,
|
||||
resetPluginShutdownForTests,
|
||||
runPluginShutdown,
|
||||
};
|
||||
69
electron/plugins/shutdownCoordinator.test.cjs
Normal file
69
electron/plugins/shutdownCoordinator.test.cjs
Normal file
@@ -0,0 +1,69 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
registerPluginShutdown,
|
||||
resetPluginShutdownForTests,
|
||||
runPluginShutdown,
|
||||
} = require("./shutdownCoordinator.cjs");
|
||||
|
||||
test("plugin shutdown is idempotent and completes before its quit deadline", async (context) => {
|
||||
context.after(resetPluginShutdownForTests);
|
||||
let calls = 0;
|
||||
registerPluginShutdown(async () => { calls += 1; });
|
||||
const [first, second] = await Promise.all([runPluginShutdown(), runPluginShutdown()]);
|
||||
assert.deepEqual(first, { timedOut: false });
|
||||
assert.deepEqual(second, { timedOut: false });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("plugin shutdown fails open after the bounded quit deadline", async (context) => {
|
||||
context.after(resetPluginShutdownForTests);
|
||||
registerPluginShutdown(async () => new Promise(() => {}));
|
||||
assert.deepEqual(await runPluginShutdown({ timeoutMs: 5 }), { timedOut: true });
|
||||
});
|
||||
|
||||
test("app shutdown waits for every registered cleanup handler", async (context) => {
|
||||
context.after(resetPluginShutdownForTests);
|
||||
const calls = [];
|
||||
let releasePortForwardCleanup;
|
||||
const portForwardCleanup = new Promise((resolve) => { releasePortForwardCleanup = resolve; });
|
||||
registerPluginShutdown(async () => { calls.push("plugins"); });
|
||||
registerPluginShutdown(async () => {
|
||||
await portForwardCleanup;
|
||||
calls.push("port-forwards");
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const shutdown = runPluginShutdown({ timeoutMs: 1_000 }).then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.deepEqual(calls, ["plugins"]);
|
||||
|
||||
releasePortForwardCleanup();
|
||||
assert.deepEqual(await shutdown, { timedOut: false });
|
||||
assert.deepEqual(calls, ["plugins", "port-forwards"]);
|
||||
});
|
||||
|
||||
test("one failed cleanup does not let app shutdown abandon other handlers", async (context) => {
|
||||
context.after(resetPluginShutdownForTests);
|
||||
let releaseRemainingCleanup;
|
||||
const remainingCleanup = new Promise((resolve) => { releaseRemainingCleanup = resolve; });
|
||||
registerPluginShutdown(async () => { throw new Error("plugin cleanup failed"); });
|
||||
registerPluginShutdown(async () => remainingCleanup);
|
||||
|
||||
let settled = false;
|
||||
const shutdown = runPluginShutdown({ timeoutMs: 1_000 }).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, "failure must not abandon another live cleanup");
|
||||
|
||||
releaseRemainingCleanup();
|
||||
await assert.rejects(shutdown, /plugin cleanup failed/);
|
||||
});
|
||||
430
electron/plugins/streamRouter.cjs
Normal file
430
electron/plugins/streamRouter.cjs
Normal file
@@ -0,0 +1,430 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
PLUGIN_RPC_DEFAULT_TIMEOUT_MS,
|
||||
PLUGIN_RPC_MAX_PENDING,
|
||||
} = require("./constants.cjs");
|
||||
const { assertStreamFrameSchema } = require("./contractValidator.cjs");
|
||||
|
||||
let contractRuntimePromise;
|
||||
function loadContractRuntime() {
|
||||
contractRuntimePromise ??= import("@netcatty/plugin-contract");
|
||||
return contractRuntimePromise;
|
||||
}
|
||||
|
||||
function getTransferredBuffer(envelope) {
|
||||
return envelope && Object.prototype.hasOwnProperty.call(envelope, "transfer")
|
||||
? envelope.transfer
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function assertStreamEnvelopeShape(envelope) {
|
||||
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
||||
throw new TypeError("Invalid plugin stream envelope");
|
||||
}
|
||||
const keys = Reflect.ownKeys(envelope);
|
||||
if (keys.some((key) => typeof key !== "string" || (key !== "frame" && key !== "transfer"))) {
|
||||
throw new TypeError("Plugin stream envelope contains unknown properties");
|
||||
}
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(envelope, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin stream envelope must contain enumerable data properties");
|
||||
}
|
||||
}
|
||||
if (!Object.hasOwn(envelope, "frame")) throw new TypeError("Plugin stream envelope is missing its frame");
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function raceWithSignal(operation, signal) {
|
||||
if (signal.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class PluginStreamRouter {
|
||||
constructor(options) {
|
||||
this.send = options.send;
|
||||
this.onIncomingStream = options.onIncomingStream ?? (() => false);
|
||||
this.incoming = new Map();
|
||||
this.outgoing = new Map();
|
||||
this.maxStreams = options.maxStreams ?? PLUGIN_RPC_MAX_PENDING;
|
||||
this.openTimeoutMs = options.openTimeoutMs ?? PLUGIN_RPC_DEFAULT_TIMEOUT_MS;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async accept(rawEnvelope) {
|
||||
if (this.closed) throw new Error("Plugin stream router is closed");
|
||||
assertStreamEnvelopeShape(rawEnvelope);
|
||||
assertStreamFrameSchema(rawEnvelope.frame);
|
||||
const contract = await loadContractRuntime();
|
||||
if (this.closed) throw new Error("Plugin stream router is closed");
|
||||
const envelope = contract.createMessagePortStreamEnvelope(
|
||||
rawEnvelope.frame,
|
||||
getTransferredBuffer(rawEnvelope),
|
||||
);
|
||||
const frame = envelope.frame;
|
||||
if (frame.kind === "open") {
|
||||
if (
|
||||
this.incoming.has(frame.streamId)
|
||||
|| this.outgoing.has(frame.streamId)
|
||||
|| this.incoming.size + this.outgoing.size >= this.maxStreams
|
||||
) {
|
||||
throw new Error(`Plugin stream cannot be opened: ${frame.streamId}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: frame.streamId,
|
||||
nextSequence: 1,
|
||||
availableBytes: frame.windowBytes,
|
||||
updateSequence: -1,
|
||||
openController: new AbortController(),
|
||||
closed: false,
|
||||
};
|
||||
this.incoming.set(frame.streamId, state);
|
||||
let accepted;
|
||||
let openTimedOut = false;
|
||||
const openTimer = setTimeout(() => {
|
||||
openTimedOut = true;
|
||||
state.openController.abort(new Error(`Plugin stream owner timed out: ${frame.streamId}`));
|
||||
}, this.openTimeoutMs);
|
||||
try {
|
||||
accepted = await raceWithSignal(Promise.resolve(this.onIncomingStream({
|
||||
streamId: frame.streamId,
|
||||
windowBytes: frame.windowBytes,
|
||||
signal: state.openController.signal,
|
||||
bind: (handlers) => this.bindIncoming(frame.streamId, handlers),
|
||||
cancel: () => this.#cancelIncoming(state),
|
||||
})), state.openController.signal);
|
||||
} catch (error) {
|
||||
if (!this.closed) {
|
||||
try {
|
||||
await this.#cancelIncoming(state, error);
|
||||
} catch {
|
||||
// The owner-selection error remains the primary protocol failure.
|
||||
}
|
||||
} else {
|
||||
state.closed = true;
|
||||
this.incoming.delete(frame.streamId);
|
||||
}
|
||||
if (openTimedOut) return;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
}
|
||||
if (accepted !== true) {
|
||||
await this.#cancelIncoming(state, new Error(`Plugin stream was not accepted: ${frame.streamId}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "windowUpdate") {
|
||||
const outgoing = this.outgoing.get(frame.streamId);
|
||||
if (!outgoing) throw new Error(`Unknown outgoing plugin stream: ${frame.streamId}`);
|
||||
if (frame.sequence !== outgoing.lastUpdateSequence + 1) {
|
||||
throw new Error(`Out-of-order stream credit update: ${frame.streamId}`);
|
||||
}
|
||||
outgoing.lastUpdateSequence = frame.sequence;
|
||||
outgoing.availableBytes += frame.creditBytes;
|
||||
if (outgoing.availableBytes > outgoing.maxCreditBytes) {
|
||||
throw new Error(`Plugin stream credit exceeds its negotiated window: ${frame.streamId}`);
|
||||
}
|
||||
if (outgoing.terminalKind === "end") {
|
||||
if (outgoing.availableBytes === outgoing.maxCreditBytes) {
|
||||
this.outgoing.delete(frame.streamId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (outgoing.closed) throw new Error(`Unknown outgoing plugin stream: ${frame.streamId}`);
|
||||
this.#flushOutgoing(outgoing);
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "cancel" && this.outgoing.has(frame.streamId)) {
|
||||
const outgoing = this.outgoing.get(frame.streamId);
|
||||
if (frame.sequence !== Math.max(1, outgoing.lastUpdateSequence + 1)) {
|
||||
throw new Error(`Out-of-order stream cancellation: ${frame.streamId}`);
|
||||
}
|
||||
outgoing.closed = true;
|
||||
this.outgoing.delete(frame.streamId);
|
||||
for (const pending of outgoing.queue) pending.reject(new Error(`Plugin stream cancelled: ${frame.streamId}`));
|
||||
outgoing.queue.length = 0;
|
||||
return;
|
||||
}
|
||||
const state = this.incoming.get(frame.streamId);
|
||||
if (!state || state.closed) throw new Error(`Unknown incoming plugin stream: ${frame.streamId}`);
|
||||
if (frame.sequence !== state.nextSequence) {
|
||||
throw new Error(`Out-of-order plugin stream frame: ${frame.streamId}`);
|
||||
}
|
||||
state.nextSequence += 1;
|
||||
if (frame.kind === "chunk") {
|
||||
const creditBytes = frame.data.byteLength;
|
||||
if (creditBytes > state.availableBytes) {
|
||||
throw new Error(`Plugin stream exceeded receive credit: ${frame.streamId}`);
|
||||
}
|
||||
state.availableBytes -= creditBytes;
|
||||
const materialized = contract.materializeStreamChunk(frame.data, envelope.transfer);
|
||||
const listener = state.onChunk;
|
||||
if (!listener) {
|
||||
await this.#cancelIncoming(state);
|
||||
return;
|
||||
}
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released || state.closed) return;
|
||||
released = true;
|
||||
state.availableBytes += creditBytes;
|
||||
state.updateSequence += 1;
|
||||
try {
|
||||
this.send({
|
||||
frame: {
|
||||
streamId: state.streamId,
|
||||
sequence: state.updateSequence,
|
||||
kind: "windowUpdate",
|
||||
creditBytes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
this.incoming.delete(state.streamId);
|
||||
state.openController.abort(error);
|
||||
try {
|
||||
const closing = state.onClose?.(error);
|
||||
if (closing && typeof closing.then === "function") void closing.catch(() => {});
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
await listener(materialized, release);
|
||||
return;
|
||||
}
|
||||
state.closed = true;
|
||||
this.incoming.delete(frame.streamId);
|
||||
const closeReason = frame.kind === "error" ? frame.error : frame.kind;
|
||||
const abortReason = frame.kind === "error"
|
||||
? frame.error
|
||||
: new Error(`Plugin stream ${frame.kind}: ${frame.streamId}`);
|
||||
state.openController.abort(abortReason);
|
||||
await state.onClose?.(closeReason);
|
||||
}
|
||||
|
||||
bindIncoming(streamId, handlers) {
|
||||
const state = this.incoming.get(streamId);
|
||||
if (!state || state.closed) throw new Error(`Unknown incoming plugin stream: ${streamId}`);
|
||||
if (!handlers || typeof handlers !== "object" || Array.isArray(handlers)) {
|
||||
throw new TypeError("Plugin incoming stream handlers must be an object");
|
||||
}
|
||||
if (typeof handlers.onChunk !== "function") {
|
||||
throw new TypeError("Plugin incoming stream onChunk handler is required");
|
||||
}
|
||||
if (handlers.onClose != null && typeof handlers.onClose !== "function") {
|
||||
throw new TypeError("Plugin incoming stream onClose handler must be a function");
|
||||
}
|
||||
state.onChunk = handlers.onChunk;
|
||||
state.onClose = handlers.onClose;
|
||||
}
|
||||
|
||||
async openOutgoing(streamId, windowBytes) {
|
||||
if (
|
||||
this.closed
|
||||
|| this.outgoing.has(streamId)
|
||||
|| this.incoming.has(streamId)
|
||||
|| this.incoming.size + this.outgoing.size >= this.maxStreams
|
||||
) {
|
||||
throw new Error(`Plugin stream cannot be opened: ${streamId}`);
|
||||
}
|
||||
const contract = await loadContractRuntime();
|
||||
if (
|
||||
this.closed
|
||||
|| this.outgoing.has(streamId)
|
||||
|| this.incoming.has(streamId)
|
||||
|| this.incoming.size + this.outgoing.size >= this.maxStreams
|
||||
) {
|
||||
throw new Error(`Plugin stream cannot be opened: ${streamId}`);
|
||||
}
|
||||
const envelope = contract.createMessagePortStreamEnvelope({
|
||||
streamId,
|
||||
sequence: 0,
|
||||
kind: "open",
|
||||
windowBytes,
|
||||
});
|
||||
const state = {
|
||||
streamId,
|
||||
nextSequence: 1,
|
||||
availableBytes: windowBytes,
|
||||
maxCreditBytes: windowBytes,
|
||||
lastUpdateSequence: -1,
|
||||
contract,
|
||||
queue: [],
|
||||
queuedBytes: 0,
|
||||
closed: false,
|
||||
terminalKind: null,
|
||||
};
|
||||
this.outgoing.set(streamId, state);
|
||||
try {
|
||||
this.send(envelope);
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
this.outgoing.delete(streamId);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
write: (data) => this.#queueOutgoing(state, data),
|
||||
end: () => this.#endOutgoing(state),
|
||||
cancel: () => this.#cancelOutgoing(state),
|
||||
};
|
||||
}
|
||||
|
||||
async #queueOutgoing(state, data) {
|
||||
if (state.closed) throw new Error(`Plugin stream is closed: ${state.streamId}`);
|
||||
const contract = await loadContractRuntime();
|
||||
if (this.closed || state.closed || this.outgoing.get(state.streamId) !== state) {
|
||||
throw new Error(`Plugin stream is closed: ${state.streamId}`);
|
||||
}
|
||||
let chunk;
|
||||
if (data instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(data.byteLength);
|
||||
copy.set(data);
|
||||
const buffer = copy.buffer;
|
||||
if (buffer.byteLength > contract.PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
||||
throw new Error(`Plugin stream chunk exceeds ${contract.PLUGIN_STREAM_MAX_CHUNK_BYTES} bytes`);
|
||||
}
|
||||
chunk = { encoding: "transfer", byteLength: buffer.byteLength, transfer: buffer };
|
||||
} else {
|
||||
chunk = contract.createJsonStreamChunk(data);
|
||||
}
|
||||
if (state.queuedBytes + chunk.byteLength > state.maxCreditBytes) {
|
||||
throw new Error(`Plugin stream pending queue exceeds its negotiated window: ${state.streamId}`);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
state.queue.push({ chunk, resolve, reject });
|
||||
state.queuedBytes += chunk.byteLength;
|
||||
this.#flushOutgoing(state);
|
||||
});
|
||||
}
|
||||
|
||||
#flushOutgoing(state) {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const pending = state.queue[0];
|
||||
if (pending.chunk.byteLength > state.availableBytes) break;
|
||||
state.queue.shift();
|
||||
state.queuedBytes -= pending.chunk.byteLength;
|
||||
state.availableBytes -= pending.chunk.byteLength;
|
||||
const frame = {
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "chunk",
|
||||
data: pending.chunk.encoding === "transfer"
|
||||
? { encoding: "transfer", byteLength: pending.chunk.byteLength }
|
||||
: pending.chunk,
|
||||
};
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
const envelope = state.contract.createMessagePortStreamEnvelope(frame, pending.chunk.transfer);
|
||||
this.send(envelope, pending.chunk.transfer ? [pending.chunk.transfer] : []);
|
||||
pending.resolve();
|
||||
} catch (error) {
|
||||
pending.reject(error);
|
||||
this.#failOutgoing(state, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#failOutgoing(state, error) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
this.outgoing.delete(state.streamId);
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
state.queue.length = 0;
|
||||
state.queuedBytes = 0;
|
||||
}
|
||||
|
||||
#finishOutgoing(state, kind) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.terminalKind = kind;
|
||||
const error = new Error(`Plugin stream ${kind}`);
|
||||
let terminalSent = false;
|
||||
try {
|
||||
this.send(state.contract.createMessagePortStreamEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind,
|
||||
}));
|
||||
terminalSent = true;
|
||||
} finally {
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
state.queue.length = 0;
|
||||
state.queuedBytes = 0;
|
||||
if (
|
||||
!terminalSent
|
||||
|| kind !== "end"
|
||||
|| state.availableBytes === state.maxCreditBytes
|
||||
) {
|
||||
this.outgoing.delete(state.streamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endOutgoing(state) {
|
||||
if (state.queue.length > 0) throw new Error("Cannot end a plugin stream with pending backpressure");
|
||||
this.#finishOutgoing(state, "end");
|
||||
}
|
||||
|
||||
#cancelOutgoing(state) {
|
||||
this.#finishOutgoing(state, "cancel");
|
||||
}
|
||||
|
||||
async #cancelIncoming(state, reason = new Error(`Plugin stream cancelled: ${state.streamId}`)) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
this.incoming.delete(state.streamId);
|
||||
state.openController.abort(reason);
|
||||
state.updateSequence = Math.max(1, state.updateSequence + 1);
|
||||
let sendError;
|
||||
try {
|
||||
this.send({ frame: { streamId: state.streamId, sequence: state.updateSequence, kind: "cancel" } });
|
||||
} catch (error) {
|
||||
sendError = error;
|
||||
}
|
||||
let closeError;
|
||||
try {
|
||||
await state.onClose?.(sendError ?? reason);
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
if (sendError) throw sendError;
|
||||
if (closeError) throw closeError;
|
||||
}
|
||||
|
||||
close(error = new Error("Plugin runtime closed")) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const state of this.outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
for (const state of this.incoming.values()) {
|
||||
state.closed = true;
|
||||
state.openController?.abort(error);
|
||||
try {
|
||||
const closing = state.onClose?.(error);
|
||||
if (closing && typeof closing.then === "function") void closing.catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
this.incoming.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginStreamRouter, assertStreamEnvelopeShape, raceWithSignal };
|
||||
429
electron/plugins/streamRouter.test.cjs
Normal file
429
electron/plugins/streamRouter.test.cjs
Normal file
@@ -0,0 +1,429 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginStreamRouter } = require("./streamRouter.cjs");
|
||||
|
||||
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
|
||||
const sent = [];
|
||||
const received = [];
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream({ bind }) {
|
||||
bind({
|
||||
async onChunk(chunk, release) {
|
||||
received.push(chunk.value);
|
||||
assert.equal(sent.length, 0);
|
||||
release();
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await router.accept({ frame: { streamId: "input", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
await router.accept({
|
||||
frame: {
|
||||
streamId: "input",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: contract.createJsonStreamChunk({ value: 1 }),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(received, [{ value: 1 }]);
|
||||
assert.deepEqual(sent[0].frame, {
|
||||
streamId: "input",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: contract.createJsonStreamChunk({ value: 1 }).byteLength,
|
||||
});
|
||||
});
|
||||
|
||||
test("incoming binary streams return their declared byte credit after release", async () => {
|
||||
const sent = [];
|
||||
const received = [];
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream({ bind }) {
|
||||
bind({
|
||||
onChunk(chunk, release) {
|
||||
received.push([...chunk.bytes]);
|
||||
release();
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
await router.accept({ frame: { streamId: "binary", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
await router.accept({
|
||||
frame: {
|
||||
streamId: "binary",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: contract.createBase64StreamChunk(bytes),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(received, [[1, 2, 3, 4]]);
|
||||
assert.deepEqual(sent[0].frame, {
|
||||
streamId: "binary",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: 4,
|
||||
});
|
||||
assert.equal(Number.isSafeInteger(sent[0].frame.creditBytes), true);
|
||||
});
|
||||
|
||||
test("outgoing stream applies byte backpressure and ordered credit updates", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
const stream = await router.openOutgoing("output", 1024);
|
||||
const source = new Uint8Array(1024);
|
||||
source[0] = 7;
|
||||
await stream.write(source);
|
||||
source[0] = 99;
|
||||
assert.equal(new Uint8Array(sent[1].transfer)[0], 7);
|
||||
let secondResolved = false;
|
||||
const second = stream.write(new Uint8Array(1)).then(() => { secondResolved = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(secondResolved, false);
|
||||
await router.accept({
|
||||
frame: { streamId: "output", sequence: 0, kind: "windowUpdate", creditBytes: 1 },
|
||||
});
|
||||
await second;
|
||||
assert.equal(sent.filter((message) => message.frame.kind === "chunk").length, 2);
|
||||
await assert.rejects(
|
||||
router.accept({ frame: { streamId: "output", sequence: 0, kind: "windowUpdate", creditBytes: 1 } }),
|
||||
/Out-of-order/,
|
||||
);
|
||||
await assert.rejects(
|
||||
router.accept({ frame: { streamId: "output", sequence: 2, kind: "windowUpdate", creditBytes: 1 } }),
|
||||
/Out-of-order/,
|
||||
);
|
||||
});
|
||||
|
||||
test("outgoing JSON stream chunks preserve the exact contract shape", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
const stream = await router.openOutgoing("json-output", 1024);
|
||||
const value = { nested: ["value", 1] };
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
|
||||
await stream.write(value);
|
||||
|
||||
assert.deepEqual(sent[1].frame.data, contract.createJsonStreamChunk(value));
|
||||
assert.equal(Object.hasOwn(sent[1].frame.data, "transfer"), false);
|
||||
assert.equal(Object.hasOwn(sent[1], "transfer"), false);
|
||||
});
|
||||
|
||||
test("ended outgoing streams accept final receive credit before retiring", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
const stream = await router.openOutgoing("ended-output", 1024);
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const chunk = contract.createJsonStreamChunk({ final: true });
|
||||
await stream.write({ final: true });
|
||||
|
||||
stream.end();
|
||||
assert.equal(sent.at(-1).frame.kind, "end");
|
||||
assert.equal(router.outgoing.has("ended-output"), true);
|
||||
await assert.rejects(stream.write({ late: true }), /closed/);
|
||||
|
||||
await router.accept({
|
||||
frame: {
|
||||
streamId: "ended-output",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: chunk.byteLength,
|
||||
},
|
||||
});
|
||||
assert.equal(router.outgoing.has("ended-output"), false);
|
||||
});
|
||||
|
||||
test("unhandled incoming streams are cancelled immediately", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
await router.accept({ frame: { streamId: "unhandled", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
assert.equal(sent[0].frame.kind, "cancel");
|
||||
});
|
||||
|
||||
test("a stalled incoming owner is aborted and cannot block later streams", async () => {
|
||||
const sent = [];
|
||||
const signals = [];
|
||||
const closed = [];
|
||||
let calls = 0;
|
||||
const router = new PluginStreamRouter({
|
||||
openTimeoutMs: 10,
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream({ bind, signal }) {
|
||||
calls += 1;
|
||||
signals.push(signal);
|
||||
if (calls === 1) {
|
||||
bind({ onChunk() {}, onClose(reason) { closed.push(reason); } });
|
||||
return new Promise(() => {});
|
||||
}
|
||||
bind({ onChunk() {} });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await router.accept({
|
||||
frame: { streamId: "stalled", sequence: 0, kind: "open", windowBytes: 1024 },
|
||||
});
|
||||
assert.equal(signals[0].aborted, true);
|
||||
assert.equal(closed[0], signals[0].reason);
|
||||
assert.equal(sent[0].frame.kind, "cancel");
|
||||
assert.equal(router.incoming.has("stalled"), false);
|
||||
|
||||
await router.accept({
|
||||
frame: { streamId: "next", sequence: 0, kind: "open", windowBytes: 1024 },
|
||||
});
|
||||
assert.equal(router.incoming.has("next"), true);
|
||||
});
|
||||
|
||||
test("incoming streams require an explicit accept and a chunk handler", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream() {},
|
||||
});
|
||||
await router.accept({ frame: { streamId: "implicit", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
assert.equal(sent[0].frame.kind, "cancel");
|
||||
|
||||
const invalid = new PluginStreamRouter({
|
||||
send() {},
|
||||
onIncomingStream({ bind }) {
|
||||
bind({});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
invalid.accept({ frame: { streamId: "invalid", sequence: 0, kind: "open", windowBytes: 1024 } }),
|
||||
/onChunk handler is required/,
|
||||
);
|
||||
assert.equal(invalid.incoming.size, 0);
|
||||
});
|
||||
|
||||
test("owner-selection failures close an already-bound incoming stream", async () => {
|
||||
const sent = [];
|
||||
const closed = [];
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream({ bind }) {
|
||||
bind({ onChunk() {}, onClose(reason) { closed.push(reason); } });
|
||||
throw new Error("owner setup failed");
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
router.accept({ frame: { streamId: "failed-owner", sequence: 0, kind: "open", windowBytes: 1024 } }),
|
||||
/owner setup failed/,
|
||||
);
|
||||
assert.equal(sent[0].frame.kind, "cancel");
|
||||
assert.match(closed[0].message, /owner setup failed/);
|
||||
assert.equal(router.incoming.size, 0);
|
||||
});
|
||||
|
||||
test("stream envelopes reject extra fields and accessors before state changes", async () => {
|
||||
const router = new PluginStreamRouter({ send() {} });
|
||||
await assert.rejects(router.accept({
|
||||
frame: { streamId: "extra", sequence: 0, kind: "open", windowBytes: 1024 },
|
||||
unexpected: true,
|
||||
}), /unknown properties/);
|
||||
const accessor = {};
|
||||
Object.defineProperty(accessor, "frame", {
|
||||
enumerable: true,
|
||||
get() { throw new Error("accessor invoked"); },
|
||||
});
|
||||
await assert.rejects(router.accept(accessor), /data properties/);
|
||||
});
|
||||
|
||||
test("peer cancellation closes only the matching outgoing stream", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
const stream = await router.openOutgoing("cancelled", 1024);
|
||||
await router.accept({
|
||||
frame: { streamId: "cancelled", sequence: 1, kind: "cancel" },
|
||||
});
|
||||
await assert.rejects(stream.write(new Uint8Array([1])), /closed/);
|
||||
const other = await router.openOutgoing("other", 1024);
|
||||
await other.write(new Uint8Array([2]));
|
||||
assert.equal(sent.at(-1).frame.streamId, "other");
|
||||
});
|
||||
|
||||
test("transport failure while opening a stream releases its identity", async () => {
|
||||
const router = new PluginStreamRouter({
|
||||
send() { throw new Error("transport closed"); },
|
||||
});
|
||||
await assert.rejects(router.openOutgoing("failed-open", 1024), /transport closed/);
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("transport failure while returning credit closes the incoming stream", async () => {
|
||||
const closed = [];
|
||||
let releaseChunk;
|
||||
const router = new PluginStreamRouter({
|
||||
send() { throw new Error("transport closed"); },
|
||||
onIncomingStream({ bind }) {
|
||||
bind({
|
||||
onChunk(_chunk, release) { releaseChunk = release; },
|
||||
onClose(reason) { closed.push(reason); },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
await router.accept({ frame: { streamId: "failed-credit", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
await router.accept({
|
||||
frame: {
|
||||
streamId: "failed-credit",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: contract.createJsonStreamChunk({ value: 1 }),
|
||||
},
|
||||
});
|
||||
assert.throws(() => releaseChunk(), /transport closed/);
|
||||
assert.equal(router.incoming.size, 0);
|
||||
assert.equal(closed.length, 1);
|
||||
assert.match(closed[0].message, /transport closed/);
|
||||
});
|
||||
|
||||
test("incoming stream completion waits for asynchronous owner cleanup", async () => {
|
||||
const sent = [];
|
||||
let ownerSignal;
|
||||
let releaseCleanup;
|
||||
const cleanup = new Promise((resolve) => { releaseCleanup = resolve; });
|
||||
let cleanupFinished = false;
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream(stream) {
|
||||
ownerSignal = stream.signal;
|
||||
stream.bind({
|
||||
onChunk() {},
|
||||
async onClose() {
|
||||
await cleanup;
|
||||
cleanupFinished = true;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await router.accept({ frame: { streamId: "cleanup", sequence: 0, kind: "open", windowBytes: 1_024 } });
|
||||
let settled = false;
|
||||
const ending = router.accept({ frame: { streamId: "cleanup", sequence: 1, kind: "end" } })
|
||||
.then(() => { settled = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.equal(ownerSignal.aborted, true);
|
||||
assert.match(ownerSignal.reason.message, /stream end/);
|
||||
releaseCleanup();
|
||||
await ending;
|
||||
assert.equal(cleanupFinished, true);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("router shutdown contains rejected asynchronous owner cleanup", async () => {
|
||||
const router = new PluginStreamRouter({
|
||||
send() {},
|
||||
onIncomingStream(stream) {
|
||||
stream.bind({
|
||||
onChunk() {},
|
||||
async onClose() { throw new Error("cleanup failed"); },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await router.accept({ frame: { streamId: "shutdown-cleanup", sequence: 0, kind: "open", windowBytes: 1_024 } });
|
||||
router.close();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
test("transport failure rejects a write and closes its outgoing stream", async () => {
|
||||
let sends = 0;
|
||||
const router = new PluginStreamRouter({
|
||||
send() {
|
||||
sends += 1;
|
||||
if (sends > 1) throw new Error("transport closed");
|
||||
},
|
||||
});
|
||||
const stream = await router.openOutgoing("failed-write", 1024);
|
||||
await assert.rejects(stream.write(new Uint8Array([1])), /transport closed/);
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
await assert.rejects(stream.write(new Uint8Array([2])), /closed/);
|
||||
});
|
||||
|
||||
test("transport failure while cancelling rejects every backpressured write", async () => {
|
||||
let failSends = false;
|
||||
const router = new PluginStreamRouter({
|
||||
send() {
|
||||
if (failSends) throw new Error("transport closed");
|
||||
},
|
||||
});
|
||||
const stream = await router.openOutgoing("failed-cancel", 1024);
|
||||
await stream.write(new Uint8Array(1024));
|
||||
const queued = stream.write(new Uint8Array([1]));
|
||||
failSends = true;
|
||||
assert.throws(() => stream.cancel(), /transport closed/);
|
||||
await assert.rejects(queued, /cancel/);
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("closing a router invalidates retained incoming release callbacks", async () => {
|
||||
const sent = [];
|
||||
let releaseChunk;
|
||||
let finishChunk;
|
||||
const chunkBlocked = new Promise((resolve) => { finishChunk = resolve; });
|
||||
const closed = [];
|
||||
let ownerSignal;
|
||||
const router = new PluginStreamRouter({
|
||||
send(message) { sent.push(message); },
|
||||
onIncomingStream({ bind, signal }) {
|
||||
ownerSignal = signal;
|
||||
bind({
|
||||
onChunk(_chunk, release) {
|
||||
releaseChunk = release;
|
||||
return chunkBlocked;
|
||||
},
|
||||
onClose(reason) { closed.push(reason); },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
await router.accept({ frame: { streamId: "closing", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
const accepting = router.accept({
|
||||
frame: {
|
||||
streamId: "closing",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: contract.createJsonStreamChunk({ value: 1 }),
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const closeError = new Error("runtime closed");
|
||||
router.close(closeError);
|
||||
assert.equal(ownerSignal.aborted, true);
|
||||
assert.equal(ownerSignal.reason, closeError);
|
||||
releaseChunk();
|
||||
finishChunk();
|
||||
await accepting;
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(closed, [closeError]);
|
||||
});
|
||||
|
||||
test("oversized binary writes fail before consuming stream credit", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
const contract = await import("@netcatty/plugin-contract");
|
||||
const stream = await router.openOutgoing("oversized", contract.PLUGIN_STREAM_MAX_WINDOW_BYTES);
|
||||
await assert.rejects(
|
||||
stream.write(new Uint8Array(contract.PLUGIN_STREAM_MAX_CHUNK_BYTES + 1)),
|
||||
/chunk exceeds/,
|
||||
);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(router.outgoing.get("oversized").availableBytes, contract.PLUGIN_STREAM_MAX_WINDOW_BYTES);
|
||||
});
|
||||
747
electron/plugins/terminalDataPipelineService.cjs
Normal file
747
electron/plugins/terminalDataPipelineService.cjs
Normal file
@@ -0,0 +1,747 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { normalizeTerminalSessionSnapshot } = require("./terminalProviderService.cjs");
|
||||
|
||||
const DIRECTIONS = Object.freeze(["input", "output"]);
|
||||
|
||||
function runtimeIdentityMatches(left, right) {
|
||||
return left?.pluginId === right?.pluginId
|
||||
&& left?.pluginVersion === right?.pluginVersion
|
||||
&& left?.runtimeId === right?.runtimeId
|
||||
&& left?.runtimeKind === right?.runtimeKind
|
||||
&& left?.securityPrincipal === right?.securityPrincipal;
|
||||
}
|
||||
|
||||
function normalizeActivationIdentity(activation) {
|
||||
const identity = activation?.identity;
|
||||
if (!identity
|
||||
|| identity.pluginId !== activation?.plugin?.id
|
||||
|| identity.pluginVersion !== activation?.plugin?.activeVersion
|
||||
|| typeof identity.runtimeId !== "string" || identity.runtimeId.length < 1
|
||||
|| identity.runtimeKind !== "utility"
|
||||
|| typeof identity.securityPrincipal !== "string" || identity.securityPrincipal.length < 1) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor activation identity is unavailable, stale, or not an advanced utility runtime",
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
});
|
||||
}
|
||||
|
||||
function terminalSessionSnapshotsEqual(left, right) {
|
||||
return left?.sessionId === right?.sessionId
|
||||
&& left?.hostId === right?.hostId
|
||||
&& left?.workspaceId === right?.workspaceId
|
||||
&& left?.protocol === right?.protocol
|
||||
&& left?.status === right?.status
|
||||
&& left?.cwd === right?.cwd
|
||||
&& left?.title === right?.title
|
||||
&& left?.shellType === right?.shellType
|
||||
&& left?.cols === right?.cols
|
||||
&& left?.rows === right?.rows
|
||||
&& left?.alternateScreen === right?.alternateScreen;
|
||||
}
|
||||
|
||||
class PluginTerminalDataPipelineService {
|
||||
constructor(options) {
|
||||
if (!options?.contributionService || !options?.permissionEngine
|
||||
|| !options?.runtimeSupervisor || !options?.MessageChannelMain) {
|
||||
throw new TypeError("Terminal data pipeline requires contribution, permission, runtime, and MessagePort services");
|
||||
}
|
||||
this.contributionService = options.contributionService;
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.MessageChannelMain = options.MessageChannelMain;
|
||||
this.requestSelection = options.requestSelection ?? (async () => null);
|
||||
this.showWarning = options.showWarning ?? (() => {});
|
||||
this.terminalWorkerManager = null;
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.active = new Map();
|
||||
this.declined = new Set();
|
||||
this.quarantined = new Set();
|
||||
this.selectedProviders = new Map();
|
||||
this.operations = new Map();
|
||||
this.pendingActivations = new Map();
|
||||
this.failedActivations = new Map();
|
||||
this.finalizedActivations = new WeakSet();
|
||||
this.permissionGenerations = new Map();
|
||||
this.sessionEpochs = new Map();
|
||||
this.nextSessionEpoch = 0;
|
||||
this.pendingOwnership = new Map();
|
||||
this.closed = false;
|
||||
this.permissionRevocationSubscription = this.permissionEngine.onDidRevoke?.((event) => {
|
||||
const pluginId = event?.pluginId;
|
||||
if (typeof pluginId !== "string") return;
|
||||
this.permissionGenerations.set(pluginId, (this.permissionGenerations.get(pluginId) ?? 0) + 1);
|
||||
const affectedDirections = event.permission === "terminal.intercept.input"
|
||||
? new Set(["input"])
|
||||
: event.permission === "terminal.intercept.output"
|
||||
? new Set(["output"])
|
||||
: event.permission === undefined || event.permission === "provider.terminal"
|
||||
? new Set(DIRECTIONS)
|
||||
: new Set();
|
||||
if (affectedDirections.size === 0) return;
|
||||
for (const [key, binding] of [...this.active]) {
|
||||
if (binding.identity.pluginId === pluginId && affectedDirections.has(binding.direction)) {
|
||||
this.#detachKey(key, "permission-revoked");
|
||||
}
|
||||
}
|
||||
for (const [key, selection] of [...this.selectedProviders]) {
|
||||
const direction = key.slice(key.lastIndexOf("\0") + 1);
|
||||
if (selection.pluginId === pluginId && affectedDirections.has(direction)) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
}) ?? null;
|
||||
this.runtimeSupervisor.onDidChangeRuntime?.((event) => {
|
||||
if (event.status === "running") return;
|
||||
const failed = event.status === "error" || event.status === "quarantined";
|
||||
const warnedKeys = new Set();
|
||||
for (const [key, selection] of this.selectedProviders) {
|
||||
if (selection.pluginId === event.pluginId) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, binding] of this.active) {
|
||||
if (binding.identity.pluginId === event.pluginId
|
||||
&& (event.runtimeId == null || binding.identity.runtimeId === event.runtimeId)) {
|
||||
if (failed) {
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: binding.sessionId,
|
||||
direction: binding.direction,
|
||||
providerId: binding.providerId,
|
||||
code: `runtime-${event.status}`,
|
||||
message: event.error
|
||||
?? `Terminal interceptor runtime ${event.status}`,
|
||||
}));
|
||||
warnedKeys.add(key);
|
||||
}
|
||||
this.#detachKey(key, failed ? `runtime-${event.status}` : "runtime-stopped");
|
||||
}
|
||||
}
|
||||
for (const [key, pending] of [
|
||||
...this.pendingActivations,
|
||||
...this.failedActivations,
|
||||
]) {
|
||||
if (this.sessionEpochs.get(pending.sessionId) !== pending.sessionEpoch) {
|
||||
if (this.failedActivations.get(key) === pending) this.failedActivations.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (pending.identity.pluginId !== event.pluginId
|
||||
|| (event.runtimeId != null
|
||||
&& pending.identity.runtimeId != null
|
||||
&& pending.identity.runtimeId !== event.runtimeId)) {
|
||||
continue;
|
||||
}
|
||||
this.finalizedActivations.add(pending);
|
||||
this.selectedProviders.delete(key);
|
||||
if (!failed) continue;
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
if (!warnedKeys.has(key)) {
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: pending.sessionId,
|
||||
direction: pending.direction,
|
||||
providerId: pending.providerId,
|
||||
code: `runtime-${event.status}`,
|
||||
message: event.error
|
||||
?? `Terminal interceptor runtime ${event.status}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (const [key, pending] of this.failedActivations) {
|
||||
if (pending.identity.pluginId === event.pluginId
|
||||
&& (event.runtimeId == null
|
||||
|| pending.identity.runtimeId == null
|
||||
|| pending.identity.runtimeId === event.runtimeId)) {
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.contributionService.onDidChange?.(() => this.#pruneContributions());
|
||||
}
|
||||
|
||||
bindTerminalWorkerManager(manager) {
|
||||
this.workerWarningSubscription?.dispose?.();
|
||||
this.sessionOwnedSubscription?.dispose?.();
|
||||
this.sessionClosedSubscription?.dispose?.();
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.terminalWorkerManager = manager ?? null;
|
||||
if (manager?.onTerminalInterceptorWarning) {
|
||||
this.workerWarningSubscription = manager.onTerminalInterceptorWarning((warning) => {
|
||||
if (warning?.code === "worker-exit") {
|
||||
this.active.clear();
|
||||
return;
|
||||
}
|
||||
const key = `${warning?.sessionId}\0${warning?.direction}`;
|
||||
const binding = this.active.get(key);
|
||||
if (!binding) return;
|
||||
if (warning?.code === "closed" && !runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(binding.identity.pluginId),
|
||||
binding.identity,
|
||||
)) {
|
||||
// RuntimeSupervisor removes the identity before an expected stop
|
||||
// closes the utility port, then publishes the authoritative stopped,
|
||||
// error, or quarantined state. Defer classification to that event so
|
||||
// planned restarts are not quarantined while crashes still are.
|
||||
return;
|
||||
}
|
||||
this.active.delete(key);
|
||||
if (!["detached", "replaced", "shutdown"].includes(warning?.code)) {
|
||||
// A circuit-breaker or protocol failure quarantines this direction
|
||||
// for the rest of the session. Keeping the cached provider would let
|
||||
// the next connected/snapshot event silently reattach the same
|
||||
// broken interceptor with its existing grants.
|
||||
this.selectedProviders.delete(key);
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
this.showWarning(warning);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (manager?.onSessionOwned) {
|
||||
this.sessionOwnedSubscription = manager.onSessionOwned((event) => (
|
||||
this.#handleSessionOwned(event)
|
||||
));
|
||||
}
|
||||
if (manager?.onSessionClosed) {
|
||||
this.sessionClosedSubscription = manager.onSessionClosed((event) => {
|
||||
if (typeof event?.sessionId === "string") this.#disposeSession(event.sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#disposeSession(sessionId) {
|
||||
this.permissionEngine.revokeSession?.(sessionId);
|
||||
this.pendingOwnership.delete(sessionId);
|
||||
// Absence is itself an invalid generation. A later reuse of the same
|
||||
// session id receives a process-unique token, so late async work from this
|
||||
// lifetime cannot match the replacement without retaining disposed ids.
|
||||
this.sessionEpochs.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
for (const direction of DIRECTIONS) {
|
||||
const key = this.#key(sessionId, direction);
|
||||
this.declined.delete(key);
|
||||
this.quarantined.delete(key);
|
||||
this.selectedProviders.delete(key);
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleSessionOwned(event) {
|
||||
const pending = this.pendingOwnership.get(event?.sessionId);
|
||||
if (!pending || pending.webContentsId !== event?.webContentsId) return;
|
||||
this.pendingOwnership.delete(event.sessionId);
|
||||
if (this.closed
|
||||
|| this.sessionEpochs.get(event.sessionId) !== pending.sessionEpoch
|
||||
|| !this.terminalWorkerManager?.ownsSession?.(event.sessionId, event.webContentsId)) {
|
||||
return;
|
||||
}
|
||||
await this.handleSessionEvent({ type: "snapshot", session: pending.session }, {
|
||||
webContentsId: pending.webContentsId,
|
||||
locale: pending.locale,
|
||||
});
|
||||
}
|
||||
|
||||
#key(sessionId, direction) {
|
||||
return `${sessionId}\0${direction}`;
|
||||
}
|
||||
|
||||
#providers(direction, locale) {
|
||||
const kind = `terminal.interceptor.${direction}`;
|
||||
return this.contributionService.listProviders({ kind, locale });
|
||||
}
|
||||
|
||||
#pruneContributions() {
|
||||
for (const [key, selection] of this.selectedProviders) {
|
||||
const direction = key.slice(key.lastIndexOf("\0") + 1);
|
||||
if (!this.#providers(direction).some((entry) => (
|
||||
entry.provider.id === selection.providerId && entry.pluginId === selection.pluginId
|
||||
))) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, binding] of this.active) {
|
||||
const providers = this.#providers(binding.direction);
|
||||
if (!providers.some((entry) => entry.provider.id === binding.providerId
|
||||
&& entry.pluginId === binding.identity.pluginId
|
||||
&& entry.pluginVersion === binding.identity.pluginVersion)) {
|
||||
this.#detachKey(key, "contribution-removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#detachKey(key, reason) {
|
||||
const binding = this.active.get(key);
|
||||
if (!binding) return;
|
||||
this.active.delete(key);
|
||||
this.terminalWorkerManager?.detachTerminalInterceptor?.(
|
||||
binding.sessionId,
|
||||
binding.direction,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
detachSession(sessionId, reason = "session-disposed") {
|
||||
for (const direction of DIRECTIONS) this.#detachKey(this.#key(sessionId, direction), reason);
|
||||
}
|
||||
|
||||
async configureDirection(sessionValue, direction, options = {}) {
|
||||
const session = normalizeTerminalSessionSnapshot(sessionValue);
|
||||
if (!DIRECTIONS.includes(direction)) throw new TypeError("Terminal interceptor direction is invalid");
|
||||
if (this.closed) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal data pipeline is closed");
|
||||
}
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
const epoch = options.sessionEpoch ?? this.#ensureSessionEpoch(session.sessionId);
|
||||
const previous = this.operations.get(key) ?? Promise.resolve();
|
||||
const operation = previous.catch(() => {}).then(() => (
|
||||
this.#configureDirection(session, direction, { ...options, sessionEpoch: epoch })
|
||||
));
|
||||
this.operations.set(key, operation);
|
||||
try { return await operation; }
|
||||
finally {
|
||||
if (this.operations.get(key) === operation) this.operations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#assertSessionCurrent(sessionId, epoch) {
|
||||
if (this.closed || this.sessionEpochs.get(sessionId) !== epoch) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal session changed during interceptor activation");
|
||||
}
|
||||
}
|
||||
|
||||
#replaceSessionEpoch(sessionId) {
|
||||
this.nextSessionEpoch += 1;
|
||||
this.sessionEpochs.set(sessionId, this.nextSessionEpoch);
|
||||
return this.nextSessionEpoch;
|
||||
}
|
||||
|
||||
#ensureSessionEpoch(sessionId) {
|
||||
return this.sessionEpochs.get(sessionId) ?? this.#replaceSessionEpoch(sessionId);
|
||||
}
|
||||
|
||||
#assertAttachmentCurrent(session, direction, providerId, identity, options) {
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager?.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session ownership changed during activation",
|
||||
);
|
||||
}
|
||||
const declared = this.#providers(direction).some((entry) => (
|
||||
entry.provider.id === providerId
|
||||
&& entry.pluginId === identity.pluginId
|
||||
&& entry.pluginVersion === identity.pluginVersion
|
||||
));
|
||||
if (!declared) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor contribution changed during activation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
acceptsSessionEvent(event, webContentsId) {
|
||||
const sessionId = event?.session?.sessionId;
|
||||
if (typeof sessionId !== "string" || !Number.isSafeInteger(webContentsId)) return true;
|
||||
const currentOwner = this.terminalWorkerManager?.getSessionOwnerWebContentsId?.(sessionId);
|
||||
return !Number.isSafeInteger(currentOwner) || currentOwner === webContentsId;
|
||||
}
|
||||
|
||||
async #configureDirection(session, direction, options) {
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (!this.terminalWorkerManager?.attachTerminalInterceptor) {
|
||||
return Object.freeze({ status: "unavailable", direction });
|
||||
}
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
if (options.deferUntilOwned === true) {
|
||||
return Object.freeze({ status: "pending-session", direction });
|
||||
}
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session is not owned by the requesting window",
|
||||
);
|
||||
}
|
||||
const providers = this.#providers(direction, options.locale);
|
||||
if (providers.length === 0) return Object.freeze({ status: "none", direction });
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
if (this.quarantined.has(key)) {
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
if (options.providerId == null && this.declined.has(key)) {
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
const existing = this.active.get(key);
|
||||
if (existing && existing.sessionEpoch === options.sessionEpoch
|
||||
&& terminalSessionSnapshotsEqual(existing.session, session)
|
||||
&& providers.some((entry) => entry.provider.id === existing.providerId)
|
||||
&& runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(existing.identity.pluginId),
|
||||
existing.identity,
|
||||
)) {
|
||||
return Object.freeze({
|
||||
status: "active",
|
||||
direction,
|
||||
providerId: existing.providerId,
|
||||
pluginId: existing.identity.pluginId,
|
||||
});
|
||||
}
|
||||
const cachedSelection = this.selectedProviders.get(key);
|
||||
let providerId = options.providerId ?? cachedSelection?.providerId;
|
||||
if (providerId != null && !providers.some((entry) => entry.provider.id === providerId)) {
|
||||
this.selectedProviders.delete(key);
|
||||
providerId = options.providerId;
|
||||
}
|
||||
if (providerId == null && providers.length === 1) providerId = providers[0].provider.id;
|
||||
if (providerId == null) {
|
||||
providerId = await this.requestSelection(Object.freeze({ session, direction, providers }));
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager?.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session ownership changed during selection",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (providerId == null) {
|
||||
this.declined.add(key);
|
||||
this.selectedProviders.delete(key);
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
const selected = providers.find((entry) => entry.provider.id === providerId);
|
||||
if (!selected) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Selected Terminal interceptor is unavailable");
|
||||
|
||||
const permissionGeneration = this.permissionGenerations.get(selected.pluginId) ?? 0;
|
||||
let pendingActivation = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
direction,
|
||||
providerId,
|
||||
identity: Object.freeze({
|
||||
pluginId: selected.pluginId,
|
||||
pluginVersion: selected.pluginVersion,
|
||||
runtimeId: null,
|
||||
}),
|
||||
});
|
||||
this.failedActivations.delete(key);
|
||||
this.pendingActivations.set(key, pendingActivation);
|
||||
try {
|
||||
let activation;
|
||||
try {
|
||||
activation = await this.contributionService.activateProvider(providerId);
|
||||
} catch (error) {
|
||||
if (this.sessionEpochs.get(session.sessionId) === options.sessionEpoch
|
||||
&& !this.quarantined.has(key)
|
||||
&& !this.finalizedActivations.has(pendingActivation)) {
|
||||
this.failedActivations.set(key, pendingActivation);
|
||||
}
|
||||
if (this.finalizedActivations.has(pendingActivation) && !this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal interceptor activation was cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
const identity = normalizeActivationIdentity(activation);
|
||||
pendingActivation = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
direction,
|
||||
providerId,
|
||||
identity,
|
||||
});
|
||||
this.pendingActivations.set(key, pendingActivation);
|
||||
if (this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor was disabled after a runtime failure",
|
||||
);
|
||||
}
|
||||
if (activation.plugin.manifest.main?.node == null
|
||||
|| activation.provider.kind !== `terminal.interceptor.${direction}`) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Terminal interceptors require an active advanced utility plugin",
|
||||
);
|
||||
}
|
||||
const context = {
|
||||
pluginId: activation.plugin.id,
|
||||
pluginVersion: activation.plugin.activeVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
manifest: activation.plugin.manifest,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
};
|
||||
try {
|
||||
for (const permission of ["provider.terminal", `terminal.intercept.${direction}`]) {
|
||||
const grant = await this.permissionEngine.authorize(context, {
|
||||
permission,
|
||||
resources: ["*"],
|
||||
sessionId: session.sessionId,
|
||||
reason: `Use ${providerId} to intercept Terminal ${direction} data`,
|
||||
operationId: `terminal.interceptor.${direction}:${providerId}`,
|
||||
allowedScopes: ["session", "application", "always"],
|
||||
validateBeforeGrant: () => {
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during authorization",
|
||||
);
|
||||
}
|
||||
if (!runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(identity.pluginId),
|
||||
identity,
|
||||
)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor runtime changed during authorization",
|
||||
);
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
},
|
||||
});
|
||||
if (!["existing", "session", "application", "always"].includes(grant?.scope)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor streams require a session, application, or persistent permission grant",
|
||||
);
|
||||
}
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) {
|
||||
this.declined.add(key);
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const currentIdentity = this.runtimeSupervisor.getRuntimeIdentity(activation.plugin.id);
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during activation",
|
||||
);
|
||||
}
|
||||
if (!runtimeIdentityMatches(currentIdentity, identity)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal interceptor runtime changed during authorization");
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
|
||||
if (existing?.sessionEpoch === options.sessionEpoch
|
||||
&& existing.providerId === providerId
|
||||
&& terminalSessionSnapshotsEqual(existing.session, session)
|
||||
&& runtimeIdentityMatches(existing.identity, identity)) {
|
||||
return Object.freeze({ status: "active", direction, providerId, pluginId: identity.pluginId });
|
||||
}
|
||||
const channel = new this.MessageChannelMain();
|
||||
const utilityDescriptor = Object.freeze({ providerId, direction, session });
|
||||
const workerDescriptor = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
providerId,
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
session,
|
||||
});
|
||||
let workerAttached = false;
|
||||
try {
|
||||
await this.runtimeSupervisor.attachTerminalInterceptor(
|
||||
identity.pluginId,
|
||||
utilityDescriptor,
|
||||
channel.port2,
|
||||
{ expectedIdentity: identity },
|
||||
);
|
||||
if (!runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(identity.pluginId),
|
||||
identity,
|
||||
)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor runtime changed during port attachment",
|
||||
);
|
||||
}
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during port attachment",
|
||||
);
|
||||
}
|
||||
if (this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor was disabled after a runtime failure",
|
||||
);
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
this.terminalWorkerManager.attachTerminalInterceptor(workerDescriptor, channel.port1);
|
||||
workerAttached = true;
|
||||
} catch (error) {
|
||||
if (!this.finalizedActivations.has(pendingActivation)) {
|
||||
this.failedActivations.set(key, pendingActivation);
|
||||
}
|
||||
if (workerAttached) {
|
||||
this.terminalWorkerManager.detachTerminalInterceptor(session.sessionId, direction);
|
||||
}
|
||||
try { channel.port1.close?.(); } catch {}
|
||||
try { channel.port2.close?.(); } catch {}
|
||||
if (this.finalizedActivations.has(pendingActivation) && !this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal interceptor activation was cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.active.set(key, Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
providerId,
|
||||
identity: Object.freeze({ ...identity }),
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
session,
|
||||
}));
|
||||
this.failedActivations.delete(key);
|
||||
this.declined.delete(key);
|
||||
this.selectedProviders.set(key, Object.freeze({
|
||||
providerId,
|
||||
pluginId: identity.pluginId,
|
||||
}));
|
||||
return Object.freeze({ status: "active", direction, providerId, pluginId: identity.pluginId });
|
||||
} finally {
|
||||
if (this.pendingActivations.get(key) === pendingActivation) {
|
||||
this.pendingActivations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleSessionEvent(event, options = {}) {
|
||||
const session = normalizeTerminalSessionSnapshot(event?.session);
|
||||
if (this.closed) return Object.freeze([]);
|
||||
if (!this.acceptsSessionEvent(event, options.webContentsId)) return Object.freeze([]);
|
||||
if (event?.type === "disposed") {
|
||||
this.#disposeSession(session.sessionId);
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (event?.type === "disconnected") {
|
||||
const sessionEpoch = this.#replaceSessionEpoch(session.sessionId);
|
||||
this.detachSession(session.sessionId, "session-disconnected");
|
||||
if (Number.isSafeInteger(options.webContentsId)) {
|
||||
this.pendingOwnership.set(session.sessionId, Object.freeze({
|
||||
session,
|
||||
sessionEpoch,
|
||||
webContentsId: options.webContentsId,
|
||||
locale: options.locale,
|
||||
}));
|
||||
} else {
|
||||
this.pendingOwnership.delete(session.sessionId);
|
||||
}
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (!["created", "connected", "reconnected", "snapshot", "cwdChanged", "titleChanged",
|
||||
"resized", "alternateScreenChanged"].includes(event?.type)) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (event.type === "created") {
|
||||
this.detachSession(session.sessionId, "session-replaced");
|
||||
for (const direction of DIRECTIONS) {
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
this.declined.delete(key);
|
||||
this.quarantined.delete(key);
|
||||
this.selectedProviders.delete(key);
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
this.#replaceSessionEpoch(session.sessionId);
|
||||
}
|
||||
const sessionEpoch = this.#ensureSessionEpoch(session.sessionId);
|
||||
if (event.type === "created" && options.webContentsId != null) {
|
||||
this.pendingOwnership.set(session.sessionId, Object.freeze({
|
||||
session,
|
||||
sessionEpoch,
|
||||
webContentsId: options.webContentsId,
|
||||
locale: options.locale,
|
||||
}));
|
||||
}
|
||||
const results = [];
|
||||
for (const direction of DIRECTIONS) {
|
||||
try {
|
||||
results.push(await this.configureDirection(session, direction, {
|
||||
...options,
|
||||
sessionEpoch,
|
||||
deferUntilOwned: event.type === "created",
|
||||
}));
|
||||
}
|
||||
catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) {
|
||||
results.push(Object.freeze({ status: "declined", direction }));
|
||||
continue;
|
||||
}
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
if (error?.code === RPC_ERRORS.cancelled
|
||||
|| this.sessionEpochs.get(session.sessionId) !== sessionEpoch
|
||||
|| this.quarantined.has(key)) {
|
||||
results.push(Object.freeze({ status: "cancelled", direction }));
|
||||
continue;
|
||||
}
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
code: "activation",
|
||||
message: error?.message ?? String(error),
|
||||
}));
|
||||
results.push(Object.freeze({ status: "failed", direction }));
|
||||
}
|
||||
}
|
||||
if (event.type === "created"
|
||||
&& !results.some((result) => result.status === "pending-session")) {
|
||||
this.pendingOwnership.delete(session.sessionId);
|
||||
}
|
||||
return Object.freeze(results);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const key of [...this.active.keys()]) this.#detachKey(key, "shutdown");
|
||||
this.declined.clear();
|
||||
this.quarantined.clear();
|
||||
this.selectedProviders.clear();
|
||||
this.pendingActivations.clear();
|
||||
this.failedActivations.clear();
|
||||
this.pendingOwnership.clear();
|
||||
this.sessionEpochs.clear();
|
||||
this.workerWarningSubscription?.dispose?.();
|
||||
this.sessionOwnedSubscription?.dispose?.();
|
||||
this.sessionClosedSubscription?.dispose?.();
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.permissionRevocationSubscription?.dispose?.();
|
||||
this.permissionRevocationSubscription = null;
|
||||
}
|
||||
|
||||
_getSessionEpochCountForTests() {
|
||||
return this.sessionEpochs.size;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DIRECTIONS,
|
||||
PluginTerminalDataPipelineService,
|
||||
normalizeActivationIdentity,
|
||||
runtimeIdentityMatches,
|
||||
};
|
||||
933
electron/plugins/terminalDataPipelineService.test.cjs
Normal file
933
electron/plugins/terminalDataPipelineService.test.cjs
Normal file
@@ -0,0 +1,933 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
|
||||
|
||||
function provider(pluginId, id, direction) {
|
||||
return Object.freeze({
|
||||
pluginId,
|
||||
pluginVersion: "1.0.0",
|
||||
pluginDisplayName: pluginId,
|
||||
provider: Object.freeze({ id, kind: `terminal.interceptor.${direction}`, label: id }),
|
||||
});
|
||||
}
|
||||
|
||||
function harness(options = {}) {
|
||||
const providers = options.providers ?? [provider("com.example", "com.example.input", "input")];
|
||||
const identity = Object.freeze({
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: options.runtimeKind ?? "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
});
|
||||
const attached = [];
|
||||
const detached = [];
|
||||
const authorized = [];
|
||||
const activationCalls = [];
|
||||
const revokedSessions = [];
|
||||
let permissionRevocationListener = null;
|
||||
const contributionListeners = [];
|
||||
const runtimeListeners = [];
|
||||
const contributionService = {
|
||||
listProviders({ kind }) { return providers.filter((entry) => entry.provider.kind === kind); },
|
||||
async activateProvider(providerId) {
|
||||
activationCalls.push(providerId);
|
||||
await options.onActivateProvider?.();
|
||||
const entry = providers.find((candidate) => candidate.provider.id === providerId);
|
||||
if (!entry) throw new Error("missing provider");
|
||||
return {
|
||||
plugin: {
|
||||
id: entry.pluginId,
|
||||
activeVersion: entry.pluginVersion,
|
||||
manifest: { main: { node: "dist/index.js" }, permissions: { required: ["runtime.advanced"] } },
|
||||
},
|
||||
provider: entry.provider,
|
||||
identity: options.activationIdentity ?? identity,
|
||||
};
|
||||
},
|
||||
onDidChange(listener) { contributionListeners.push(listener); },
|
||||
};
|
||||
const runtimeSupervisor = {
|
||||
getRuntimeIdentity() { return options.getRuntimeIdentity ? options.getRuntimeIdentity() : identity; },
|
||||
onDidChangeRuntime(listener) { runtimeListeners.push(listener); },
|
||||
async attachTerminalInterceptor(pluginId, descriptor, port, attachOptions) {
|
||||
port.unref?.();
|
||||
attached.push({ side: "plugin", pluginId, descriptor, port, attachOptions });
|
||||
await options.onAttachTerminalInterceptor?.();
|
||||
},
|
||||
};
|
||||
const permissionEngine = {
|
||||
async authorize(context, request) {
|
||||
authorized.push({ context, request });
|
||||
await options.onAuthorize?.({ context, request, providers });
|
||||
request.validateBeforeGrant?.();
|
||||
const scope = typeof options.permissionScope === "function"
|
||||
? options.permissionScope({ context, request, call: authorized.length })
|
||||
: options.permissionScope ?? "session";
|
||||
return { scope };
|
||||
},
|
||||
revokeSession(sessionId) { revokedSessions.push(sessionId); },
|
||||
onDidRevoke(listener) {
|
||||
permissionRevocationListener = listener;
|
||||
return { dispose: () => { permissionRevocationListener = null; } };
|
||||
},
|
||||
};
|
||||
const worker = {
|
||||
warningListener: null,
|
||||
ownedListener: null,
|
||||
closedListener: null,
|
||||
ownsSession() { return true; },
|
||||
getSessionOwnerWebContentsId() {
|
||||
return options.sessionOwnerWebContentsId ?? null;
|
||||
},
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
port.unref?.();
|
||||
attached.push({ side: "worker", descriptor, port });
|
||||
},
|
||||
detachTerminalInterceptor(sessionId, direction) { detached.push({ sessionId, direction }); },
|
||||
onTerminalInterceptorWarning(listener) {
|
||||
this.warningListener = listener;
|
||||
return { dispose: () => { this.warningListener = null; } };
|
||||
},
|
||||
onSessionOwned(listener) {
|
||||
this.ownedListener = listener;
|
||||
return { dispose: () => { this.ownedListener = null; } };
|
||||
},
|
||||
onSessionClosed(listener) {
|
||||
this.closedListener = listener;
|
||||
return { dispose: () => { this.closedListener = null; } };
|
||||
},
|
||||
};
|
||||
const selections = [];
|
||||
const warnings = [];
|
||||
const service = new PluginTerminalDataPipelineService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
runtimeSupervisor,
|
||||
MessageChannelMain: MessageChannel,
|
||||
requestSelection: async (request) => {
|
||||
selections.push(request);
|
||||
return Object.hasOwn(options, "selectedProviderId")
|
||||
? options.selectedProviderId
|
||||
: request.providers[0].provider.id;
|
||||
},
|
||||
showWarning: (warning) => warnings.push(warning),
|
||||
});
|
||||
service.bindTerminalWorkerManager(worker);
|
||||
return {
|
||||
service,
|
||||
worker,
|
||||
identity,
|
||||
attached,
|
||||
detached,
|
||||
authorized,
|
||||
activationCalls,
|
||||
revokedSessions,
|
||||
selections,
|
||||
warnings,
|
||||
runtimeListeners,
|
||||
revokePermission(event) { permissionRevocationListener?.(event); },
|
||||
contributionListeners,
|
||||
contributionService,
|
||||
};
|
||||
}
|
||||
|
||||
const session = Object.freeze({
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
test("pipeline activation requires exact session permissions and transfers one port to each process", async () => {
|
||||
const h = harness();
|
||||
const result = await h.service.configureDirection(session, "input");
|
||||
assert.deepEqual(result, {
|
||||
status: "active",
|
||||
direction: "input",
|
||||
providerId: "com.example.input",
|
||||
pluginId: "com.example",
|
||||
});
|
||||
assert.deepEqual(h.authorized.map((entry) => entry.request.permission), [
|
||||
"provider.terminal",
|
||||
"terminal.intercept.input",
|
||||
]);
|
||||
assert.ok(h.authorized.every((entry) => entry.request.sessionId === "session-1"));
|
||||
assert.ok(h.authorized.every((entry) => (
|
||||
JSON.stringify(entry.request.allowedScopes) === JSON.stringify(["session", "application", "always"])
|
||||
)));
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
assert.deepEqual(h.attached[0].attachOptions.expectedIdentity, h.identity);
|
||||
assert.deepEqual(h.attached[0].descriptor, {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session,
|
||||
});
|
||||
assert.deepEqual(h.attached[1].descriptor, {
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.input",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
session,
|
||||
});
|
||||
});
|
||||
|
||||
test("pipeline rejects one-use permission grants before opening a streaming port", async () => {
|
||||
const h = harness({ permissionScope: "once" });
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/require a session, application, or persistent permission grant/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
|
||||
const laterOnce = harness({
|
||||
permissionScope: ({ call }) => (call === 2 ? "once" : "session"),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => laterOnce.service.configureDirection(session, "input"),
|
||||
/require a session, application, or persistent permission grant/,
|
||||
);
|
||||
assert.equal(laterOnce.authorized.length, 2);
|
||||
assert.equal(laterOnce.attached.length, 0);
|
||||
});
|
||||
|
||||
test("permission denial is remembered for the session instead of prompting again", async () => {
|
||||
const h = harness({
|
||||
onAuthorize() {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "permission denied");
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
(error) => error?.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
h.contributionListeners[0]();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("session event permission denial is a quiet declined result", async () => {
|
||||
const h = harness({
|
||||
onAuthorize() {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "permission denied");
|
||||
},
|
||||
});
|
||||
const results = await h.service.handleSessionEvent({ type: "connected", session }, {
|
||||
webContentsId: 99,
|
||||
});
|
||||
assert.deepEqual(results, [
|
||||
{ status: "declined", direction: "input" },
|
||||
{ status: "none", direction: "output" },
|
||||
]);
|
||||
assert.deepEqual(h.warnings, []);
|
||||
});
|
||||
|
||||
test("pipeline accepts an existing long-lived permission grant", async () => {
|
||||
const h = harness({ permissionScope: "existing" });
|
||||
const result = await h.service.configureDirection(session, "input");
|
||||
assert.equal(result.status, "active");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
});
|
||||
|
||||
test("multiple interceptors require an explicit per-session selection", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1);
|
||||
assert.equal(h.selections[0].providers.length, 2);
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1, "an active session binding must not prompt again");
|
||||
});
|
||||
|
||||
test("declining competing interceptors is remembered for the session and reset on disposal", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: null,
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.selections.length, 1);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 2);
|
||||
});
|
||||
|
||||
test("concurrent snapshots serialize to one authorization and one port pair", async () => {
|
||||
const h = harness();
|
||||
const [first, second] = await Promise.all([
|
||||
h.service.configureDirection(session, "input"),
|
||||
h.service.configureDirection(session, "input"),
|
||||
]);
|
||||
assert.equal(first.status, "active");
|
||||
assert.equal(second.status, "active");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("browser runtimes cannot receive privileged terminal ports", async () => {
|
||||
const h = harness({ runtimeKind: "browser" });
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/advanced utility runtime/,
|
||||
);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("stale activation identity fails before permissions or port transfer", async () => {
|
||||
const h = harness({
|
||||
activationIdentity: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "0.9.0",
|
||||
runtimeId: "stale-runtime",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/identity is unavailable, stale/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("worker transfer starts only after the plugin port is ready", async () => {
|
||||
const h = harness();
|
||||
h.worker.attachTerminalInterceptor = () => { throw new Error("worker unavailable"); };
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/worker unavailable/,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("contribution withdrawal during authorization prevents stale port publication", async () => {
|
||||
let withdrawn = false;
|
||||
const h = harness({
|
||||
onAuthorize({ providers }) {
|
||||
if (withdrawn) return;
|
||||
withdrawn = true;
|
||||
providers.splice(0, providers.length);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/contribution changed/,
|
||||
);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("runtime replacement during port attachment cannot publish a stale active binding", async () => {
|
||||
let runtimeId = "runtime-1";
|
||||
const h = harness({
|
||||
getRuntimeIdentity: () => ({
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId,
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}),
|
||||
onAttachTerminalInterceptor() { runtimeId = "runtime-2"; },
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/runtime changed during port attachment/,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.equal(h.service.active.size, 0);
|
||||
});
|
||||
|
||||
test("disconnect detaches and reconnect restores the remembered session interceptor", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(h.selections.length, 1);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disconnected",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 99 });
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
|
||||
await h.worker.ownedListener({ sessionId: session.sessionId, webContentsId: 99 });
|
||||
assert.equal(h.selections.length, 1, "reconnect ownership must reuse the session-local choice");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
|
||||
await h.service.handleSessionEvent({ type: "reconnected", session });
|
||||
assert.equal(h.selections.length, 1, "reconnect must reuse the session-local choice");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
assert.equal(h.attached.at(-1).descriptor.session.status, "connected");
|
||||
});
|
||||
|
||||
test("session metadata changes refresh the interceptor invocation snapshot", async () => {
|
||||
const h = harness();
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
await h.service.handleSessionEvent({
|
||||
type: "cwdChanged",
|
||||
session: { ...session, cwd: "/srv/current" },
|
||||
});
|
||||
assert.equal(h.attached.at(-1).descriptor.session.cwd, "/srv/current");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
assert.deepEqual(h.detached, [], "snapshot refresh must replace the worker port atomically");
|
||||
});
|
||||
|
||||
test("revoking a terminal permission immediately detaches the matching active interceptor", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
h.revokePermission({
|
||||
pluginId: "com.example",
|
||||
permission: "terminal.intercept.input",
|
||||
resource: "*",
|
||||
scope: "application",
|
||||
});
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
});
|
||||
|
||||
test("a worker-confirmed terminal exit revokes session grants and clears pipeline state", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
|
||||
h.worker.closedListener({ sessionId: session.sessionId, reason: "exited" });
|
||||
|
||||
assert.deepEqual(h.revokedSessions, [session.sessionId]);
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: session.sessionId, direction: "input" }]);
|
||||
});
|
||||
|
||||
test("a renderer cannot attach an interceptor to another window's terminal session", async () => {
|
||||
const h = harness();
|
||||
h.worker.ownsSession = () => false;
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input", { webContentsId: 99 }),
|
||||
/not owned by the requesting window/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during provider selection prevents authorization", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
});
|
||||
let owned = true;
|
||||
let releaseSelection;
|
||||
h.worker.ownsSession = () => owned;
|
||||
h.service.requestSelection = async () => {
|
||||
await new Promise((resolve) => { releaseSelection = resolve; });
|
||||
return "com.example.input";
|
||||
};
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseSelection) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseSelection();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during selection/);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during permission approval prevents a lasting grant", async () => {
|
||||
let owned = true;
|
||||
let releaseAuthorization;
|
||||
const h = harness({
|
||||
onAuthorize: () => new Promise((resolve) => { releaseAuthorization = resolve; }),
|
||||
});
|
||||
h.worker.ownsSession = () => owned;
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseAuthorization) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseAuthorization();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during activation/);
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during port attachment prevents worker publication", async () => {
|
||||
let owned = true;
|
||||
let releaseAttach;
|
||||
const h = harness({
|
||||
onAttachTerminalInterceptor: () => new Promise((resolve) => { releaseAttach = resolve; }),
|
||||
});
|
||||
h.worker.ownsSession = () => owned;
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseAttach) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseAttach();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during activation/);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("created events defer silently until the worker has recorded session ownership", async () => {
|
||||
const h = harness();
|
||||
let owned = false;
|
||||
h.worker.ownsSession = () => owned;
|
||||
const results = await h.service.handleSessionEvent({ type: "created", session }, {
|
||||
webContentsId: 99,
|
||||
});
|
||||
assert.deepEqual(results, [
|
||||
{ status: "pending-session", direction: "input" },
|
||||
{ status: "pending-session", direction: "output" },
|
||||
]);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
|
||||
owned = true;
|
||||
await h.worker.ownedListener({ sessionId: session.sessionId, webContentsId: 99 });
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
assert.equal(h.authorized.length, 2);
|
||||
});
|
||||
|
||||
test("session disposal invalidates an in-flight lazy activation before port transfer", async () => {
|
||||
const h = harness();
|
||||
const originalActivate = h.contributionService.activateProvider;
|
||||
let releaseActivation;
|
||||
h.contributionService.activateProvider = async (...args) => {
|
||||
await new Promise((resolve) => { releaseActivation = resolve; });
|
||||
return originalActivate(...args);
|
||||
};
|
||||
const pending = h.service.configureDirection(session, "input");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
releaseActivation();
|
||||
await assert.rejects(pending, /session changed/);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("a stale renderer disposal cannot detach the current owner's interceptor", async () => {
|
||||
const h = harness({ sessionOwnerWebContentsId: 9 });
|
||||
await h.service.configureDirection(session, "input", { webContentsId: 9 });
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 7 });
|
||||
|
||||
assert.equal(h.service.active.size, 1);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.deepEqual(h.revokedSessions, []);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 9 });
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
assert.deepEqual(h.revokedSessions, ["session-1"]);
|
||||
});
|
||||
|
||||
test("a stale renderer cannot publish any lifecycle event for the current owner's session", async () => {
|
||||
const h = harness({ sessionOwnerWebContentsId: 9 });
|
||||
await h.service.configureDirection(session, "input", { webContentsId: 9 });
|
||||
for (const type of ["created", "disconnected", "connected", "cwdChanged"]) {
|
||||
const result = await h.service.handleSessionEvent({ type, session }, { webContentsId: 7 });
|
||||
assert.deepEqual(result, []);
|
||||
}
|
||||
assert.equal(h.service.active.size, 1);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.deepEqual(h.revokedSessions, []);
|
||||
});
|
||||
|
||||
test("runtime failure statuses quarantine both directions before detaching", async () => {
|
||||
for (const status of ["error", "quarantined"]) {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.example", "com.example.output", "output"),
|
||||
],
|
||||
});
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
h.runtimeListeners[0]({
|
||||
status,
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
error: "utility runtime crashed",
|
||||
});
|
||||
assert.deepEqual(h.detached.map((entry) => entry.direction).sort(), ["input", "output"]);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code).sort(), [
|
||||
`runtime-${status}`,
|
||||
`runtime-${status}`,
|
||||
]);
|
||||
const reconnect = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.deepEqual(reconnect.map((result) => result.status), ["declined", "declined"]);
|
||||
assert.equal(h.authorized.length, 4, "a crashed runtime must not be authorized again");
|
||||
assert.equal(h.attached.length, 4, "a crashed runtime must not be reattached");
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("runtime exit clears the cached provider choice but ordinary reconnect preserves it", async () => {
|
||||
const providers = [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
];
|
||||
const h = harness({
|
||||
providers,
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1);
|
||||
|
||||
const withdrawn = providers.splice(0);
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
providers.push(...withdrawn);
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 2, "a stopped runtime must discard its session-local choice");
|
||||
});
|
||||
|
||||
test("an expected runtime close waits for the stopped event instead of quarantining", async () => {
|
||||
let currentIdentity;
|
||||
const h = harness({ getRuntimeIdentity: () => currentIdentity });
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
currentIdentity = null;
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "closed",
|
||||
message: "Terminal interceptor stopped and was disabled",
|
||||
});
|
||||
assert.equal(h.warnings.length, 0);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
assert.equal(h.warnings.length, 0);
|
||||
assert.equal(h.authorized.length, 4);
|
||||
assert.equal(h.attached.length, 4);
|
||||
});
|
||||
|
||||
test("a close during runtime failure is quarantined by the authoritative error event", async () => {
|
||||
let currentIdentity;
|
||||
const h = harness({ getRuntimeIdentity: () => currentIdentity });
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
currentIdentity = null;
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "closed",
|
||||
message: "Terminal interceptor stopped and was disabled",
|
||||
});
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
error: "utility runtime crashed",
|
||||
});
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("terminal worker exit invalidates active bindings before worker restart", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
h.worker.warningListener({ code: "worker-exit", message: "Terminal worker exited" });
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.authorized.length, 4);
|
||||
assert.equal(h.attached.length, 4);
|
||||
});
|
||||
|
||||
test("a failed interceptor stays quarantined for the rest of the session", async () => {
|
||||
const h = harness();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "timeout",
|
||||
message: "Interceptor timed out",
|
||||
});
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.equal(h.authorized.length, 2, "the quarantined provider must not be authorized again");
|
||||
assert.equal(h.attached.length, 2, "the quarantined provider must not be reattached");
|
||||
assert.equal(h.warnings.length, 1);
|
||||
});
|
||||
|
||||
test("permission revocation while a port is attaching prevents the worker attachment", async () => {
|
||||
let releaseAttach;
|
||||
const attachGate = new Promise((resolve) => { releaseAttach = resolve; });
|
||||
const h = harness({ onAttachTerminalInterceptor: () => attachGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.attached.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.revokePermission({ pluginId: "com.example", permission: "terminal.intercept.input" });
|
||||
releaseAttach();
|
||||
|
||||
await assert.rejects(
|
||||
() => activation,
|
||||
(error) => error?.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("a concurrent metadata refresh cannot revive a quarantined interceptor", async () => {
|
||||
let attachCalls = 0;
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
|
||||
const h = harness({
|
||||
onAttachTerminalInterceptor() {
|
||||
attachCalls += 1;
|
||||
return attachCalls === 2 ? refreshGate : undefined;
|
||||
},
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
const refresh = h.service.configureDirection({ ...session, title: "new title" }, "input");
|
||||
while (h.attached.length < 3) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "timeout",
|
||||
message: "Interceptor timed out",
|
||||
});
|
||||
releaseRefresh();
|
||||
|
||||
await assert.rejects(
|
||||
() => refresh,
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker", "plugin"]);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
});
|
||||
|
||||
test("a runtime crash reported after its first port attachment fails still quarantines the interceptor", async () => {
|
||||
let rejectAttach;
|
||||
const attachGate = new Promise((_resolve, reject) => { rejectAttach = reject; });
|
||||
const h = harness({ onAttachTerminalInterceptor: () => attachGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.attached.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
rejectAttach(new Error("plugin runtime transport closed"));
|
||||
await assert.rejects(() => activation, /transport closed/);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "utility runtime crashed during attachment",
|
||||
});
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("a runtime crash during first provider activation prevents automatic retry", async () => {
|
||||
let rejectActivation;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const h = harness({ onActivateProvider: () => activationGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "utility runtime crashed during activation",
|
||||
});
|
||||
rejectActivation(new Error("plugin activation transport closed"));
|
||||
|
||||
await assert.rejects(() => activation, /transport closed/);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.activationCalls.length, 1);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("a late crash from an old session generation cannot quarantine a reused session id", async () => {
|
||||
let rejectOldActivation;
|
||||
let activationCalls = 0;
|
||||
const oldActivationGate = new Promise((_resolve, reject) => { rejectOldActivation = reject; });
|
||||
const h = harness({
|
||||
onActivateProvider() {
|
||||
activationCalls += 1;
|
||||
return activationCalls === 1 ? oldActivationGate : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const oldActivation = h.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
await h.service.handleSessionEvent({ type: "disposed", session });
|
||||
const newActivation = h.service.handleSessionEvent({ type: "created", session });
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "old utility runtime crashed late",
|
||||
});
|
||||
rejectOldActivation(new Error("old plugin activation transport closed"));
|
||||
|
||||
const oldResult = await oldActivation;
|
||||
const newResult = await newActivation;
|
||||
assert.equal(oldResult[0].status, "cancelled");
|
||||
assert.notEqual(newResult[0].status, "declined");
|
||||
assert.equal(h.activationCalls.length, 2);
|
||||
assert.deepEqual(h.warnings.filter((warning) => warning.code === "runtime-error"), []);
|
||||
});
|
||||
|
||||
test("a normal stop before provider activation rejects cannot become a later crash record", async () => {
|
||||
let rejectActivation;
|
||||
let activationCalls = 0;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const h = harness({
|
||||
onActivateProvider() {
|
||||
activationCalls += 1;
|
||||
return activationCalls === 1 ? activationGate : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const first = h.service.configureDirection(session, "input");
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
});
|
||||
rejectActivation(new Error("plugin stopped during activation"));
|
||||
await assert.rejects(
|
||||
() => first,
|
||||
(error) => error?.code === RPC_ERRORS.cancelled,
|
||||
);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "unrelated later crash",
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
assert.equal(h.activationCalls.length, 2);
|
||||
assert.deepEqual(h.warnings, []);
|
||||
});
|
||||
|
||||
test("normal cancellation and an already-reported crash do not emit duplicate activation warnings", async () => {
|
||||
let rejectActivation;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const normal = harness({ onActivateProvider: () => activationGate });
|
||||
const normalEvent = normal.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (normal.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
normal.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: normal.identity.runtimeId,
|
||||
});
|
||||
rejectActivation(new Error("runtime stopped"));
|
||||
const normalResults = await normalEvent;
|
||||
assert.equal(normalResults[0].status, "cancelled");
|
||||
assert.deepEqual(normal.warnings, []);
|
||||
|
||||
let rejectCrash;
|
||||
const crashGate = new Promise((_resolve, reject) => { rejectCrash = reject; });
|
||||
const crashed = harness({ onActivateProvider: () => crashGate });
|
||||
const crashEvent = crashed.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (crashed.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
crashed.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: crashed.identity.runtimeId,
|
||||
error: "runtime crashed",
|
||||
});
|
||||
rejectCrash(new Error("transport closed"));
|
||||
const crashResults = await crashEvent;
|
||||
assert.equal(crashResults[0].status, "cancelled");
|
||||
assert.deepEqual(crashed.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("contribution changes do not clear a failed interceptor quarantine", async () => {
|
||||
const h = harness();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "protocol",
|
||||
message: "Interceptor returned an invalid frame",
|
||||
});
|
||||
h.contributionListeners[0]();
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "snapshot", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("disposed terminal sessions do not retain lifecycle generations", async () => {
|
||||
const h = harness({ providers: [] });
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
const nextSession = {
|
||||
...session,
|
||||
sessionId: `disposed-session-${index}`,
|
||||
};
|
||||
await h.service.handleSessionEvent({ type: "created", session: nextSession });
|
||||
await h.service.handleSessionEvent({ type: "disposed", session: nextSession });
|
||||
}
|
||||
assert.equal(h.service._getSessionEpochCountForTests(), 0);
|
||||
});
|
||||
63
electron/plugins/terminalInterceptorMessages.cjs
Normal file
63
electron/plugins/terminalInterceptorMessages.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
const { escapePermissionText } = require("./nativePermissionDecision.cjs");
|
||||
|
||||
const MESSAGES = Object.freeze({
|
||||
en: Object.freeze({
|
||||
noInterceptor: "No interceptor",
|
||||
selectTitle: (direction) => `Terminal ${direction} interceptor`,
|
||||
selectMessage: (direction) => `Multiple plugins can intercept Terminal ${direction} data. Choose one for this session.`,
|
||||
warningTitle: "Terminal interceptor disabled",
|
||||
warningMessage: "A Terminal interceptor was disabled for this session.",
|
||||
warningDetail: "The plugin failed or exceeded its data-path budget.",
|
||||
}),
|
||||
"zh-CN": Object.freeze({
|
||||
noInterceptor: "不使用拦截器",
|
||||
selectTitle: (direction) => `终端${direction === "input" ? "输入" : "输出"}拦截器`,
|
||||
selectMessage: (direction) => `多个插件可以拦截终端${direction === "input" ? "输入" : "输出"}数据。请选择本会话使用的插件。`,
|
||||
warningTitle: "终端拦截器已停用",
|
||||
warningMessage: "本会话的一个终端拦截器已被停用。",
|
||||
warningDetail: "插件失败或超过了数据通道预算。",
|
||||
}),
|
||||
"zh-TW": Object.freeze({
|
||||
noInterceptor: "不使用攔截器",
|
||||
selectTitle: (direction) => `終端${direction === "input" ? "輸入" : "輸出"}攔截器`,
|
||||
selectMessage: (direction) => `多個外掛程式可以攔截終端${direction === "input" ? "輸入" : "輸出"}資料。請選擇本工作階段使用的外掛程式。`,
|
||||
warningTitle: "終端攔截器已停用",
|
||||
warningMessage: "本工作階段的一個終端攔截器已被停用。",
|
||||
warningDetail: "外掛程式失敗或超過資料通道預算。",
|
||||
}),
|
||||
ru: Object.freeze({
|
||||
noInterceptor: "Без перехватчика",
|
||||
selectTitle: (direction) => `Перехватчик ${direction === "input" ? "ввода" : "вывода"} терминала`,
|
||||
selectMessage: (direction) => `Несколько плагинов могут перехватывать ${direction === "input" ? "ввод" : "вывод"} терминала. Выберите один для этого сеанса.`,
|
||||
warningTitle: "Перехватчик терминала отключён",
|
||||
warningMessage: "Перехватчик терминала был отключён для этого сеанса.",
|
||||
warningDetail: "Плагин завершился с ошибкой или превысил бюджет канала данных.",
|
||||
}),
|
||||
});
|
||||
|
||||
function terminalInterceptorMessages(locale) {
|
||||
return MESSAGES[locale] ?? MESSAGES[String(locale).split("-")[0]] ?? MESSAGES.en;
|
||||
}
|
||||
|
||||
function terminalInterceptorChoiceLabel(entry) {
|
||||
const plugin = escapePermissionText(
|
||||
entry?.pluginDisplayName ?? entry?.pluginId ?? "Plugin",
|
||||
160,
|
||||
);
|
||||
const provider = escapePermissionText(entry?.provider?.label ?? entry?.provider?.id ?? "Provider", 160);
|
||||
const pluginId = escapePermissionText(entry?.pluginId ?? "unknown-plugin", 128);
|
||||
const providerId = escapePermissionText(entry?.provider?.id ?? "unknown-provider", 192);
|
||||
return `${plugin} (${pluginId}): ${provider} (${providerId})`;
|
||||
}
|
||||
|
||||
function terminalInterceptorIdentifier(value) {
|
||||
return escapePermissionText(value ?? "", 256);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
terminalInterceptorChoiceLabel,
|
||||
terminalInterceptorIdentifier,
|
||||
terminalInterceptorMessages,
|
||||
};
|
||||
47
electron/plugins/terminalInterceptorMessages.test.cjs
Normal file
47
electron/plugins/terminalInterceptorMessages.test.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
terminalInterceptorChoiceLabel,
|
||||
terminalInterceptorIdentifier,
|
||||
terminalInterceptorMessages,
|
||||
} = require("./terminalInterceptorMessages.cjs");
|
||||
|
||||
test("terminal interceptor native prompts cover every application locale and fall back to English", () => {
|
||||
for (const locale of ["en", "zh-CN", "zh-TW", "ru"]) {
|
||||
const messages = terminalInterceptorMessages(locale);
|
||||
assert.ok(messages.noInterceptor);
|
||||
assert.ok(messages.selectTitle("input"));
|
||||
assert.ok(messages.selectMessage("output"));
|
||||
assert.ok(messages.warningTitle);
|
||||
}
|
||||
assert.equal(terminalInterceptorMessages("fr").noInterceptor, "No interceptor");
|
||||
});
|
||||
|
||||
test("plugin-controlled native selection labels visibly escape control and bidi text", () => {
|
||||
assert.equal(terminalInterceptorChoiceLabel({
|
||||
pluginId: "com.example.safe",
|
||||
pluginDisplayName: "safe\nname\u202e",
|
||||
provider: { id: "com.example.input", label: "input\tfilter" },
|
||||
}), "safe\\nname\\u202e (com.example.safe): input\\tfilter (com.example.input)");
|
||||
assert.equal(terminalInterceptorIdentifier("provider\r\u2066"), "provider\\r\\u2066");
|
||||
});
|
||||
|
||||
test("selection labels keep distinct valid provider identifiers visible in full", () => {
|
||||
const shared = `com.example.${"a".repeat(170)}`;
|
||||
const first = terminalInterceptorChoiceLabel({
|
||||
pluginId: "com.example",
|
||||
pluginDisplayName: "Plugin",
|
||||
provider: { id: `${shared}.first`, label: "Filter" },
|
||||
});
|
||||
const second = terminalInterceptorChoiceLabel({
|
||||
pluginId: "com.example",
|
||||
pluginDisplayName: "Plugin",
|
||||
provider: { id: `${shared}.other`, label: "Filter" },
|
||||
});
|
||||
assert.notEqual(first, second);
|
||||
assert.match(first, /\.first\)$/u);
|
||||
assert.match(second, /\.other\)$/u);
|
||||
});
|
||||
70
electron/plugins/terminalInterceptorTransport.cjs
Normal file
70
electron/plugins/terminalInterceptorTransport.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
const schema = require("./generated/plugin-contract.schema.json");
|
||||
const { assertTerminalInterceptorFrameSchema } = require("./contractValidator.cjs");
|
||||
|
||||
const limits = schema.$defs.TerminalInterceptorLimits.const;
|
||||
const TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = limits.maxChunkBytes;
|
||||
const TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = limits.maxWindowBytes;
|
||||
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(
|
||||
ArrayBuffer.prototype,
|
||||
"byteLength",
|
||||
)?.get;
|
||||
|
||||
function copyOwnDataRecord(value, label) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be a plain object`);
|
||||
}
|
||||
const copy = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(`${label} must contain data properties only`);
|
||||
}
|
||||
copy[key] = descriptor.value;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function transferredByteLength(value) {
|
||||
if (!arrayBufferByteLength) throw new TypeError("ArrayBuffer byteLength getter is unavailable");
|
||||
try {
|
||||
const byteLength = arrayBufferByteLength.call(value);
|
||||
// The intrinsic getter reports zero for a detached zero-length buffer as
|
||||
// well as for a valid empty buffer. Constructing a view distinguishes the
|
||||
// two and also works for ArrayBuffers created in another realm.
|
||||
new Uint8Array(value);
|
||||
return byteLength;
|
||||
} catch {
|
||||
throw new TypeError("Terminal interceptor transfer requires a real, attached ArrayBuffer");
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalInterceptorEnvelope(frameValue, transfer) {
|
||||
const frame = copyOwnDataRecord(frameValue, "Terminal interceptor frame");
|
||||
assertTerminalInterceptorFrameSchema(frame);
|
||||
const requiresTransfer = frame.type === "netcatty:terminal-interceptor:chunk"
|
||||
|| (frame.type === "netcatty:terminal-interceptor:result" && frame.status === "ok");
|
||||
if (!requiresTransfer) {
|
||||
if (transfer !== undefined) {
|
||||
throw new TypeError("This terminal interceptor frame must not include a transferred buffer");
|
||||
}
|
||||
return Object.freeze({ frame: Object.freeze(frame) });
|
||||
}
|
||||
if (transfer === undefined) {
|
||||
throw new TypeError("This terminal interceptor frame requires a transferred ArrayBuffer");
|
||||
}
|
||||
const byteLength = transferredByteLength(transfer);
|
||||
if (byteLength !== frame.byteLength) {
|
||||
throw new RangeError(
|
||||
`Terminal interceptor byteLength mismatch: declared ${frame.byteLength}, received ${byteLength}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ frame: Object.freeze(frame), transfer });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
};
|
||||
124
electron/plugins/terminalInterceptorTransport.test.cjs
Normal file
124
electron/plugins/terminalInterceptorTransport.test.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { runInNewContext } = require("node:vm");
|
||||
|
||||
const {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = require("./terminalInterceptorTransport.cjs");
|
||||
|
||||
test("terminal interceptor envelopes validate canonical ready, chunk, and result frames", () => {
|
||||
assert.deepEqual(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId: "session-1",
|
||||
direction: "output",
|
||||
windowBytes: TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
}), {
|
||||
frame: {
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId: "session-1",
|
||||
direction: "output",
|
||||
windowBytes: TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
},
|
||||
});
|
||||
|
||||
const chunkData = new Uint8Array([1, 2, 3]).buffer;
|
||||
const chunk = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
byteLength: 3,
|
||||
}, chunkData);
|
||||
assert.equal(chunk.transfer, chunkData);
|
||||
|
||||
const resultData = runInNewContext("new Uint8Array([4, 5]).buffer");
|
||||
const result = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: Number.MAX_SAFE_INTEGER,
|
||||
status: "ok",
|
||||
creditBytes: 3,
|
||||
byteLength: 2,
|
||||
}, resultData);
|
||||
assert.equal(result.transfer, resultData);
|
||||
|
||||
assert.deepEqual(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: 2,
|
||||
status: "failed",
|
||||
}), {
|
||||
frame: {
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: 2,
|
||||
status: "failed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal interceptor envelopes fail closed on schema or transfer mismatches", () => {
|
||||
const data = new ArrayBuffer(4);
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 0,
|
||||
direction: "input",
|
||||
creditBytes: 1,
|
||||
byteLength: 4,
|
||||
}, data),
|
||||
/violates the plugin contract/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 1,
|
||||
byteLength: 3,
|
||||
}, data),
|
||||
/byteLength mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: 1,
|
||||
status: "ok",
|
||||
creditBytes: 4,
|
||||
byteLength: 4,
|
||||
}),
|
||||
/requires a transferred ArrayBuffer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
windowBytes: TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
extra: true,
|
||||
}),
|
||||
/violates the plugin contract/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: 1,
|
||||
status: "failed",
|
||||
}, data),
|
||||
/must not include a transferred buffer/,
|
||||
);
|
||||
|
||||
const detached = new ArrayBuffer(0);
|
||||
structuredClone(detached, { transfer: [detached] });
|
||||
assert.throws(
|
||||
() => createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence: 1,
|
||||
direction: "input",
|
||||
creditBytes: 1,
|
||||
byteLength: 0,
|
||||
}, detached),
|
||||
/requires a real, attached ArrayBuffer/,
|
||||
);
|
||||
});
|
||||
825
electron/plugins/terminalProviderService.cjs
Normal file
825
electron/plugins/terminalProviderService.cjs
Normal file
@@ -0,0 +1,825 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const {
|
||||
assertProviderRequest,
|
||||
assertProviderResult,
|
||||
} = require("./contractValidator.cjs");
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_PROVIDER_JSON_BYTES = 128 * 1024;
|
||||
const MAX_TERMINAL_PROVIDERS_PER_REQUEST = 32;
|
||||
const DEFAULT_PROVIDER_DEADLINE_MS = 1_500;
|
||||
const TERMINAL_PROVIDER_KINDS = Object.freeze([
|
||||
"terminal.completion",
|
||||
"terminal.decoration",
|
||||
"terminal.link",
|
||||
"terminal.hover",
|
||||
"terminal.matcher",
|
||||
"terminal.semantic",
|
||||
"terminal.prompt",
|
||||
"terminal.background",
|
||||
"terminal.theme",
|
||||
]);
|
||||
const TERMINAL_PROVIDER_KIND_SET = new Set(TERMINAL_PROVIDER_KINDS);
|
||||
const TERMINAL_PROVIDER_OPERATIONS = new Map([
|
||||
["terminal.completion", "provideCompletions"],
|
||||
["terminal.decoration", "provideDecorations"],
|
||||
["terminal.link", "provideLinks"],
|
||||
["terminal.hover", "provideHovers"],
|
||||
["terminal.matcher", "provideMatches"],
|
||||
["terminal.semantic", "provideSemantics"],
|
||||
["terminal.prompt", "provideAnnotations"],
|
||||
["terminal.background", "provideBackgrounds"],
|
||||
["terminal.theme", "provideTheme"],
|
||||
]);
|
||||
const TERMINAL_PROVIDER_PERMISSIONS = new Map([
|
||||
["terminal.completion", ["provider.terminal", "terminal.complete"]],
|
||||
["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]],
|
||||
["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.background", ["provider.terminal", "terminal.decorate"]],
|
||||
["terminal.theme", ["provider.terminal", "terminal.decorate"]],
|
||||
]);
|
||||
const TERMINAL_THEME_COLOR_KEYS = Object.freeze([
|
||||
"background", "foreground", "cursor", "selection",
|
||||
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
||||
"brightBlack", "brightRed", "brightGreen", "brightYellow",
|
||||
"brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
||||
]);
|
||||
const TERMINAL_THEME_COLOR_KEY_SET = new Set(TERMINAL_THEME_COLOR_KEYS);
|
||||
const TERMINAL_EVENT_TYPES = new Set([
|
||||
"snapshot",
|
||||
"created",
|
||||
"connected",
|
||||
"reconnected",
|
||||
"cwdChanged",
|
||||
"titleChanged",
|
||||
"resized",
|
||||
"alternateScreenChanged",
|
||||
"commandSubmitted",
|
||||
"commandCompleted",
|
||||
"disconnected",
|
||||
"disposed",
|
||||
]);
|
||||
const TERMINAL_STATUSES = new Set(["connecting", "connected", "disconnected"]);
|
||||
const TERMINAL_SHELL_TYPES = new Set(["posix", "fish", "powershell", "cmd", "unknown"]);
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
function freezeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function assertBoundedJson(value, label) {
|
||||
try {
|
||||
assertPluginJsonValue(value, { maxBytes: MAX_PROVIDER_JSON_BYTES });
|
||||
} catch (error) {
|
||||
throw invalidArgument(`${label} must be a bounded JSON value: ${error?.message ?? error}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertBoundedString(value, label, maximum = 256) {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > maximum || value.includes("\0")) {
|
||||
throw invalidArgument(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertTerminalProviderKind(kind) {
|
||||
if (!TERMINAL_PROVIDER_KIND_SET.has(kind)) {
|
||||
if (kind === "terminal.interceptor.input" || kind === "terminal.interceptor.output") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Raw terminal interceptor providers require the privileged PR 6 transport",
|
||||
);
|
||||
}
|
||||
throw invalidArgument("Terminal Provider kind is invalid");
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
function normalizeDeadlineMs(value) {
|
||||
const deadlineMs = value ?? DEFAULT_PROVIDER_DEADLINE_MS;
|
||||
if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000) {
|
||||
throw invalidArgument("Terminal Provider deadline is invalid");
|
||||
}
|
||||
return deadlineMs;
|
||||
}
|
||||
|
||||
function throwIfCancelled(signal) {
|
||||
if (signal?.aborted) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal Provider request was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
function optionalString(value, label, maximum = 512) {
|
||||
if (value == null) return undefined;
|
||||
return assertBoundedString(value, label, maximum);
|
||||
}
|
||||
|
||||
function normalizeTerminalProtocol(value) {
|
||||
if (typeof value !== "string"
|
||||
|| value.length < 1
|
||||
|| value.length > 256
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value)) {
|
||||
throw invalidArgument("Terminal session protocol is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTerminalSessionSnapshot(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw invalidArgument("Terminal session snapshot must be an object");
|
||||
}
|
||||
const protocol = normalizeTerminalProtocol(value.protocol);
|
||||
const status = value.status;
|
||||
if (!TERMINAL_STATUSES.has(status)) throw invalidArgument("Terminal session status is invalid");
|
||||
if (value.shellType != null && !TERMINAL_SHELL_TYPES.has(value.shellType)) {
|
||||
throw invalidArgument("Terminal session shell type is invalid");
|
||||
}
|
||||
if (value.alternateScreen != null && typeof value.alternateScreen !== "boolean") {
|
||||
throw invalidArgument("Terminal alternate-screen state is invalid");
|
||||
}
|
||||
const normalizeDimension = (dimension, label) => {
|
||||
if (dimension == null) return undefined;
|
||||
if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16_384) {
|
||||
throw invalidArgument(`Terminal session ${label} is invalid`);
|
||||
}
|
||||
return dimension;
|
||||
};
|
||||
return freezeJson({
|
||||
sessionId: assertBoundedString(value.sessionId, "Terminal session ID"),
|
||||
...(value.hostId == null ? {} : { hostId: optionalString(value.hostId, "Terminal host ID") }),
|
||||
...(value.workspaceId == null ? {} : { workspaceId: optionalString(value.workspaceId, "Terminal workspace ID") }),
|
||||
protocol,
|
||||
status,
|
||||
...(value.cwd == null ? {} : { cwd: optionalString(value.cwd, "Terminal working directory", 4_096) }),
|
||||
...(value.title == null ? {} : { title: optionalString(value.title, "Terminal title", 1_024) }),
|
||||
...(value.shellType == null ? {} : { shellType: value.shellType }),
|
||||
...(value.cols == null ? {} : { cols: normalizeDimension(value.cols, "column count") }),
|
||||
...(value.rows == null ? {} : { rows: normalizeDimension(value.rows, "row count") }),
|
||||
...(value.alternateScreen == null ? {} : { alternateScreen: value.alternateScreen === true }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTerminalSessionEvent(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || !TERMINAL_EVENT_TYPES.has(value.type)) {
|
||||
throw invalidArgument("Terminal session event is invalid");
|
||||
}
|
||||
const event = {
|
||||
type: value.type,
|
||||
session: normalizeTerminalSessionSnapshot(value.session),
|
||||
...(value.exitCode == null ? {} : { exitCode: value.exitCode }),
|
||||
};
|
||||
if (event.exitCode != null && !Number.isSafeInteger(event.exitCode)) {
|
||||
throw invalidArgument("Terminal session exit code is invalid");
|
||||
}
|
||||
// PR 5 lifecycle events intentionally exclude command text and raw output.
|
||||
// Sensitive input/output interception belongs to the privileged PR 6 path.
|
||||
assertBoundedJson(event, "Terminal session event");
|
||||
return freezeJson(event);
|
||||
}
|
||||
|
||||
function assertPlainRecord(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertVisibleString(value, label, maximum) {
|
||||
assertBoundedString(value, label, maximum);
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x1f
|
||||
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|
||||
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|
||||
|| (codePoint >= 0x2066 && codePoint <= 0x2069)) {
|
||||
throw new TypeError(`${label} contains unsafe control characters`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertProviderTextRanges(value, options) {
|
||||
const result = assertPlainRecord(value, `${options.label} result`);
|
||||
const items = result[options.property];
|
||||
if (!Array.isArray(items) || items.length > 64) {
|
||||
throw new TypeError(`${options.label} result is invalid`);
|
||||
}
|
||||
const line = options.payload?.line;
|
||||
if (typeof line !== "string" || line.length > 8_192) {
|
||||
throw new TypeError(`${options.label} payload line is invalid`);
|
||||
}
|
||||
for (const candidate of items) {
|
||||
const item = assertPlainRecord(candidate, `${options.label} item`);
|
||||
if (!Number.isInteger(item.start)
|
||||
|| !Number.isInteger(item.length)
|
||||
|| item.start < 0
|
||||
|| item.length < 1
|
||||
|| item.start > line.length
|
||||
|| item.length > line.length - item.start) {
|
||||
throw new TypeError(`${options.label} range is invalid`);
|
||||
}
|
||||
options.assertItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOptionalColor(value, label) {
|
||||
if (value == null) return;
|
||||
const color = assertVisibleString(value, label, 9);
|
||||
if (!/^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u.test(color)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAnnotations(value, label) {
|
||||
if (!Array.isArray(value) || value.length > 8) throw new TypeError(`${label} is invalid`);
|
||||
for (const candidate of value) {
|
||||
const annotation = assertPlainRecord(candidate, `${label} item`);
|
||||
assertVisibleString(annotation.text, `${label} text`, 512);
|
||||
assertOptionalColor(annotation.color, `${label} color`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatcherLines(payload) {
|
||||
if (!Array.isArray(payload?.lines) || payload.lines.length < 1 || payload.lines.length > 32) {
|
||||
throw new TypeError("Terminal matcher payload lines are invalid");
|
||||
}
|
||||
const lines = new Map();
|
||||
let characterCount = 0;
|
||||
for (const candidate of payload.lines) {
|
||||
const item = assertPlainRecord(candidate, "Terminal matcher payload line");
|
||||
const lineId = assertVisibleString(item.lineId, "Terminal matcher line ID", 64);
|
||||
const line = assertVisibleString(item.line, "Terminal matcher line", 8_192);
|
||||
characterCount += line.length;
|
||||
if (characterCount > 96 * 1024) {
|
||||
throw new TypeError("Terminal matcher payload exceeds its line budget");
|
||||
}
|
||||
if (lines.has(lineId)) throw new TypeError("Terminal matcher line IDs must be unique");
|
||||
if (!Number.isInteger(item.bufferLineNumber) || item.bufferLineNumber < 1) {
|
||||
throw new TypeError("Terminal matcher buffer line number is invalid");
|
||||
}
|
||||
lines.set(lineId, line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function assertCompletionResult(value) {
|
||||
const result = assertPlainRecord(value, "Terminal completion result");
|
||||
if (!Array.isArray(result.items) || result.items.length > 100) {
|
||||
throw new TypeError("Terminal completion items are invalid");
|
||||
}
|
||||
for (const candidate of result.items) {
|
||||
const item = assertPlainRecord(candidate, "Terminal completion item");
|
||||
const text = assertVisibleString(item.text, "Terminal completion text", 4_096);
|
||||
if (item.displayText != null
|
||||
&& assertVisibleString(item.displayText, "Terminal completion display text", 4_096) !== text) {
|
||||
throw new TypeError("Terminal completion display text must match the inserted text");
|
||||
}
|
||||
if (item.description != null) {
|
||||
assertVisibleString(item.description, "Terminal completion description", 2_048);
|
||||
}
|
||||
if (item.score != null && (typeof item.score !== "number" || !Number.isFinite(item.score))) {
|
||||
throw new TypeError("Terminal completion score is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertDecorationResult(value) {
|
||||
const result = assertPlainRecord(value, "Terminal decoration result");
|
||||
if (!Array.isArray(result.rules) || result.rules.length > 64) {
|
||||
throw new TypeError("Terminal decoration rules are invalid");
|
||||
}
|
||||
for (const candidate of result.rules) {
|
||||
const rule = assertPlainRecord(candidate, "Terminal decoration rule");
|
||||
assertVisibleString(rule.id, "Terminal decoration rule ID", 128);
|
||||
assertVisibleString(rule.label, "Terminal decoration rule label", 256);
|
||||
if (!Array.isArray(rule.patterns) || rule.patterns.length < 1 || rule.patterns.length > 16) {
|
||||
throw new TypeError("Terminal decoration rule patterns are invalid");
|
||||
}
|
||||
for (const pattern of rule.patterns) {
|
||||
assertVisibleString(pattern, "Terminal decoration pattern", 512);
|
||||
}
|
||||
if (typeof rule.color !== "string") {
|
||||
throw new TypeError("Terminal decoration rule color is required");
|
||||
}
|
||||
assertOptionalColor(rule.color, "Terminal decoration rule color");
|
||||
}
|
||||
}
|
||||
|
||||
function assertTerminalProviderPayload(kind, operation, payload) {
|
||||
if (TERMINAL_PROVIDER_OPERATIONS.get(kind) !== operation) {
|
||||
throw new TypeError("Terminal Provider operation does not match its kind");
|
||||
}
|
||||
if (kind === "terminal.completion") {
|
||||
const input = assertVisibleString(payload?.input, "Terminal completion input", 4_096);
|
||||
if (!Number.isInteger(payload.cursor) || payload.cursor < 0 || payload.cursor > input.length) {
|
||||
throw new TypeError("Terminal completion cursor is invalid");
|
||||
}
|
||||
if (!["linux", "windows", "macos"].includes(payload.hostOs)) {
|
||||
throw new TypeError("Terminal completion host OS is invalid");
|
||||
}
|
||||
if (payload.cwdSource != null && !["prompt", "fallback", "none"].includes(payload.cwdSource)) {
|
||||
throw new TypeError("Terminal completion CWD source is invalid");
|
||||
}
|
||||
if (!Number.isInteger(payload.maximum) || payload.maximum < 1 || payload.maximum > 100) {
|
||||
throw new TypeError("Terminal completion maximum is invalid");
|
||||
}
|
||||
}
|
||||
if (kind === "terminal.decoration") {
|
||||
assertVisibleString(payload?.reason, "Terminal decoration reason", 128);
|
||||
}
|
||||
if (kind === "terminal.link" || kind === "terminal.hover") {
|
||||
assertVisibleString(payload?.line, "Terminal Provider line", 8_192);
|
||||
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
|
||||
throw new TypeError("Terminal Provider buffer line number is invalid");
|
||||
}
|
||||
}
|
||||
if (kind === "terminal.matcher") assertMatcherLines(payload);
|
||||
if (kind === "terminal.semantic") {
|
||||
assertVisibleString(payload?.command, "Terminal semantic command", 4_096);
|
||||
}
|
||||
if (kind === "terminal.prompt") {
|
||||
if (payload?.reason !== "commandCompleted") throw new TypeError("Terminal prompt reason is invalid");
|
||||
if (payload.promptLine != null) {
|
||||
assertVisibleString(payload.promptLine, "Terminal prompt line", 8_192);
|
||||
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
|
||||
throw new TypeError("Terminal prompt buffer line number is invalid");
|
||||
}
|
||||
} else if (payload.bufferLineNumber != null) {
|
||||
throw new TypeError("Terminal prompt line number requires a prompt line");
|
||||
}
|
||||
}
|
||||
if (kind === "terminal.background") {
|
||||
assertVisibleString(payload?.reason, "Terminal background reason", 128);
|
||||
assertOptionalColor(payload.terminalBackground, "Terminal background color");
|
||||
}
|
||||
if (kind === "terminal.theme") {
|
||||
assertVisibleString(payload?.reason, "Terminal theme reason", 128);
|
||||
const currentTheme = assertPlainRecord(payload?.currentTheme, "Current terminal theme");
|
||||
if (currentTheme.type !== "dark" && currentTheme.type !== "light") {
|
||||
throw new TypeError("Current terminal theme type is invalid");
|
||||
}
|
||||
const colors = assertPlainRecord(currentTheme.colors, "Current terminal theme colors");
|
||||
for (const key of TERMINAL_THEME_COLOR_KEYS) {
|
||||
if (typeof colors[key] !== "string") throw new TypeError(`Current terminal theme ${key} is required`);
|
||||
assertOptionalColor(colors[key], `Current terminal theme ${key}`);
|
||||
}
|
||||
if (Object.keys(colors).some((key) => !TERMINAL_THEME_COLOR_KEY_SET.has(key))) {
|
||||
throw new TypeError("Current terminal theme contains an unknown color");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertTerminalProviderOkResult(kind, value, payload) {
|
||||
if (kind === "terminal.completion") assertCompletionResult(value);
|
||||
if (kind === "terminal.decoration") assertDecorationResult(value);
|
||||
if (kind === "terminal.link") {
|
||||
assertProviderTextRanges(value, {
|
||||
label: "Terminal link",
|
||||
property: "links",
|
||||
payload,
|
||||
assertItem(item) {
|
||||
const uri = assertVisibleString(item.uri, "Terminal link URI", 2_048);
|
||||
const parsed = new URL(uri);
|
||||
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
|| parsed.username || parsed.password) {
|
||||
throw new TypeError("Terminal link URI is invalid");
|
||||
}
|
||||
if (item.label != null) assertVisibleString(item.label, "Terminal link label", 256);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (kind === "terminal.hover") {
|
||||
assertProviderTextRanges(value, {
|
||||
label: "Terminal hover",
|
||||
property: "hovers",
|
||||
payload,
|
||||
assertItem(item) {
|
||||
assertVisibleString(item.contents, "Terminal hover contents", 2_048);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (kind === "terminal.matcher") {
|
||||
const result = assertPlainRecord(value, "Terminal matcher result");
|
||||
const lines = assertMatcherLines(payload);
|
||||
if (!Array.isArray(result.matches) || result.matches.length > 64) {
|
||||
throw new TypeError("Terminal matcher result is invalid");
|
||||
}
|
||||
for (const candidate of result.matches) {
|
||||
const item = assertPlainRecord(candidate, "Terminal matcher item");
|
||||
const lineId = assertVisibleString(item.lineId, "Terminal matcher line ID", 64);
|
||||
const line = lines.get(lineId);
|
||||
if (line === undefined
|
||||
|| !Number.isInteger(item.start)
|
||||
|| !Number.isInteger(item.length)
|
||||
|| item.start < 0
|
||||
|| item.length < 1
|
||||
|| item.start > line.length
|
||||
|| item.length > line.length - item.start) {
|
||||
throw new TypeError("Terminal matcher range is invalid");
|
||||
}
|
||||
assertVisibleString(item.label, "Terminal matcher label", 256);
|
||||
if (item.severity != null && !["info", "warning", "error", "success"].includes(item.severity)) {
|
||||
throw new TypeError("Terminal matcher severity is invalid");
|
||||
}
|
||||
assertOptionalColor(item.color, "Terminal matcher color");
|
||||
}
|
||||
}
|
||||
if (kind === "terminal.semantic") {
|
||||
const result = assertPlainRecord(value, "Terminal semantic result");
|
||||
const command = payload?.command;
|
||||
assertVisibleString(command, "Terminal semantic command", 4_096);
|
||||
if (result.classification != null) {
|
||||
assertVisibleString(result.classification, "Terminal semantic classification", 128);
|
||||
}
|
||||
if (result.description != null) {
|
||||
assertVisibleString(result.description, "Terminal semantic description", 1_024);
|
||||
}
|
||||
if (result.destructive != null && typeof result.destructive !== "boolean") {
|
||||
throw new TypeError("Terminal semantic destructive flag is invalid");
|
||||
}
|
||||
if (result.idempotent != null && typeof result.idempotent !== "boolean") {
|
||||
throw new TypeError("Terminal semantic idempotent flag is invalid");
|
||||
}
|
||||
if (result.annotations != null) assertAnnotations(result.annotations, "Terminal semantic annotations");
|
||||
}
|
||||
if (kind === "terminal.prompt") {
|
||||
const result = assertPlainRecord(value, "Terminal prompt result");
|
||||
if (payload?.promptLine != null) {
|
||||
assertVisibleString(payload.promptLine, "Terminal prompt line", 8_192);
|
||||
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
|
||||
throw new TypeError("Terminal prompt buffer line number is invalid");
|
||||
}
|
||||
}
|
||||
assertAnnotations(result.annotations, "Terminal prompt annotations");
|
||||
}
|
||||
if (kind === "terminal.background") {
|
||||
const result = assertPlainRecord(value, "Terminal background result");
|
||||
if (result.refreshAfterMs != null
|
||||
&& (!Number.isInteger(result.refreshAfterMs)
|
||||
|| result.refreshAfterMs < 250
|
||||
|| result.refreshAfterMs > 60_000)) {
|
||||
throw new TypeError("Terminal background refresh cadence is invalid");
|
||||
}
|
||||
if (!Array.isArray(result.layers) || result.layers.length > 4) {
|
||||
throw new TypeError("Terminal background layers are invalid");
|
||||
}
|
||||
for (const candidate of result.layers) {
|
||||
const layer = assertPlainRecord(candidate, "Terminal background layer");
|
||||
assertVisibleString(layer.id, "Terminal background layer ID", 128);
|
||||
if (typeof layer.color !== "string") {
|
||||
throw new TypeError("Terminal background layer color is required");
|
||||
}
|
||||
assertOptionalColor(layer.color, "Terminal background layer color");
|
||||
if (layer.opacity != null
|
||||
&& (typeof layer.opacity !== "number"
|
||||
|| !Number.isFinite(layer.opacity)
|
||||
|| layer.opacity < 0
|
||||
|| layer.opacity > 0.35)) {
|
||||
throw new TypeError("Terminal background layer opacity is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kind === "terminal.theme") {
|
||||
const result = assertPlainRecord(value, "Terminal theme result");
|
||||
const colors = assertPlainRecord(result.colors, "Terminal theme colors");
|
||||
if (Object.keys(result).some((key) => key !== "colors")
|
||||
|| Object.keys(colors).some((key) => !TERMINAL_THEME_COLOR_KEY_SET.has(key))) {
|
||||
throw new TypeError("Terminal theme result contains an unknown property");
|
||||
}
|
||||
for (const [key, color] of Object.entries(colors)) {
|
||||
assertOptionalColor(color, `Terminal theme ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function providerFailure(requestId, error) {
|
||||
if (error instanceof PluginRpcError && error.code === RPC_ERRORS.cancelled) {
|
||||
return Object.freeze({ requestId, status: "cancelled" });
|
||||
}
|
||||
return freezeJson({
|
||||
requestId,
|
||||
status: "failed",
|
||||
error: {
|
||||
code: error instanceof PluginRpcError ? error.code : RPC_ERRORS.internal,
|
||||
message: String(error?.message ?? "Plugin Provider failed").slice(0, 2_048) || "Plugin Provider failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeActivationIdentity(activation) {
|
||||
const identity = activation?.identity;
|
||||
if (!identity
|
||||
|| typeof identity.pluginId !== "string"
|
||||
|| identity.pluginId !== activation.plugin.id
|
||||
|| typeof identity.pluginVersion !== "string"
|
||||
|| identity.pluginVersion !== activation.plugin.activeVersion
|
||||
|| typeof identity.runtimeId !== "string"
|
||||
|| identity.runtimeId.length < 1
|
||||
|| typeof identity.runtimeKind !== "string"
|
||||
|| identity.runtimeKind.length < 1
|
||||
|| typeof identity.securityPrincipal !== "string"
|
||||
|| identity.securityPrincipal.length < 1) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin Provider activation identity is unavailable or stale");
|
||||
}
|
||||
return Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeIdentityMatches(current, expected) {
|
||||
return current?.pluginId === expected.pluginId
|
||||
&& current?.pluginVersion === expected.pluginVersion
|
||||
&& current?.runtimeId === expected.runtimeId
|
||||
&& current?.runtimeKind === expected.runtimeKind
|
||||
&& current?.securityPrincipal === expected.securityPrincipal;
|
||||
}
|
||||
|
||||
class PluginTerminalProviderService {
|
||||
constructor(options) {
|
||||
if (!options?.contributionService || !options?.runtimeSupervisor || !options?.permissionEngine) {
|
||||
throw new TypeError("Terminal Provider service requires contribution, runtime, and permission services");
|
||||
}
|
||||
this.contributionService = options.contributionService;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.lifecycleAuthorizations = new Map();
|
||||
this.contributionService.onDidChange?.(() => {
|
||||
this.pruneLifecycleAuthorizations(this.activeTerminalProviderPluginIds());
|
||||
});
|
||||
}
|
||||
|
||||
lifecycleAuthorizationKey(pluginId, sessionId) {
|
||||
return `${pluginId}\0${sessionId}`;
|
||||
}
|
||||
|
||||
activeTerminalProviderPluginIds() {
|
||||
const pluginIds = new Set();
|
||||
for (const entry of this.contributionService.listProviders()) {
|
||||
if (TERMINAL_PROVIDER_KIND_SET.has(entry.provider.kind)) pluginIds.add(entry.pluginId);
|
||||
}
|
||||
return pluginIds;
|
||||
}
|
||||
|
||||
pruneLifecycleAuthorizations(pluginIds, sessionId = null) {
|
||||
for (const [key, authorization] of this.lifecycleAuthorizations) {
|
||||
if ((sessionId == null || authorization.sessionId === sessionId)
|
||||
&& !pluginIds.has(authorization.identity.pluginId)) {
|
||||
this.lifecycleAuthorizations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rememberLifecycleAuthorization(activation, identity, sessionId) {
|
||||
this.lifecycleAuthorizations.set(
|
||||
this.lifecycleAuthorizationKey(activation.plugin.id, sessionId),
|
||||
Object.freeze({
|
||||
context: Object.freeze({
|
||||
pluginId: activation.plugin.id,
|
||||
pluginVersion: activation.plugin.activeVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
manifest: activation.plugin.manifest,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
}),
|
||||
identity,
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
listProviders(options = {}) {
|
||||
const kind = assertTerminalProviderKind(options.kind);
|
||||
const preferredProviderIds = options.preferredProviderIds ?? [];
|
||||
if (!Array.isArray(preferredProviderIds)
|
||||
|| preferredProviderIds.length > 256
|
||||
|| preferredProviderIds.some((id) => typeof id !== "string" || id.length < 1 || id.length > 256)
|
||||
|| new Set(preferredProviderIds).size !== preferredProviderIds.length) {
|
||||
throw invalidArgument("Preferred Terminal Provider IDs are invalid");
|
||||
}
|
||||
if (options.locale != null
|
||||
&& (typeof options.locale !== "string" || options.locale.length < 1 || options.locale.length > 128)) {
|
||||
throw invalidArgument("Terminal Provider locale is invalid");
|
||||
}
|
||||
const preferred = new Map(preferredProviderIds.map((id, index) => [id, index]));
|
||||
const providers = [...this.contributionService.listProviders({ kind, locale: options.locale })];
|
||||
providers.sort((left, right) => {
|
||||
const leftRank = preferred.get(left.provider.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightRank = preferred.get(right.provider.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftRank - rightRank
|
||||
|| left.pluginId.localeCompare(right.pluginId)
|
||||
|| left.provider.id.localeCompare(right.provider.id);
|
||||
});
|
||||
return freezeJson(providers);
|
||||
}
|
||||
|
||||
async invokeProvider(params, options = {}) {
|
||||
const kind = assertTerminalProviderKind(params?.kind);
|
||||
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
|
||||
const request = {
|
||||
providerId: params?.providerId,
|
||||
operation: params?.operation,
|
||||
requestId: params?.requestId ?? `provider-${randomUUID()}`,
|
||||
...(params?.payload === undefined ? {} : { payload: assertBoundedJson(params.payload, "Provider payload") }),
|
||||
deadlineMs,
|
||||
};
|
||||
try { assertProviderRequest(request); }
|
||||
catch (error) { throw invalidArgument(error?.message ?? "Provider request is invalid"); }
|
||||
throwIfCancelled(options.signal);
|
||||
const activationOperation = Promise.resolve(
|
||||
this.contributionService.activateProvider(request.providerId),
|
||||
);
|
||||
const activation = options.signal
|
||||
? await raceWithAbort(activationOperation, options.signal)
|
||||
: await activationOperation;
|
||||
throwIfCancelled(options.signal);
|
||||
if (activation.provider.kind !== kind) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin Provider kind changed during activation");
|
||||
}
|
||||
const identity = normalizeActivationIdentity(activation);
|
||||
const invocationSession = request.payload && typeof request.payload === "object" && !Array.isArray(request.payload)
|
||||
? request.payload.session
|
||||
: null;
|
||||
const permissionSession = invocationSession == null
|
||||
? null
|
||||
: normalizeTerminalSessionSnapshot(invocationSession);
|
||||
let providerPermissionScope;
|
||||
for (const permission of TERMINAL_PROVIDER_PERMISSIONS.get(kind) ?? []) {
|
||||
const grant = await this.permissionEngine.authorize({
|
||||
pluginId: activation.plugin.id,
|
||||
pluginVersion: activation.plugin.activeVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
manifest: activation.plugin.manifest,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
signal: options.signal,
|
||||
}, {
|
||||
permission,
|
||||
resources: ["*"],
|
||||
reason: `Use ${activation.provider.id} for ${kind}`,
|
||||
operationId: `${kind}:${activation.provider.id}`,
|
||||
...(permissionSession == null ? {} : { sessionId: permissionSession.sessionId }),
|
||||
});
|
||||
if (permission === "provider.terminal") providerPermissionScope = grant?.scope;
|
||||
}
|
||||
const expectedIdentity = { expectedIdentity: identity };
|
||||
if (permissionSession != null) {
|
||||
await this.runtimeSupervisor.notify(activation.plugin.id, "plugin.terminal.event", {
|
||||
type: "snapshot",
|
||||
session: permissionSession,
|
||||
}, expectedIdentity);
|
||||
}
|
||||
const rawResult = await this.runtimeSupervisor.request(activation.plugin.id, "provider.invoke", {
|
||||
...request,
|
||||
kind,
|
||||
}, {
|
||||
signal: options.signal,
|
||||
timeoutMs: deadlineMs,
|
||||
...expectedIdentity,
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
assertProviderResult(rawResult);
|
||||
assertBoundedJson(rawResult, "Provider result");
|
||||
if (rawResult.status === "ok") {
|
||||
assertTerminalProviderOkResult(kind, rawResult.result, request.payload);
|
||||
}
|
||||
result = rawResult;
|
||||
} catch (error) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, `Plugin Provider returned an invalid result: ${error?.message ?? error}`);
|
||||
}
|
||||
if (result.requestId !== request.requestId) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin Provider returned a mismatched request ID");
|
||||
}
|
||||
if (result.status === "ok" && permissionSession != null && providerPermissionScope !== "once") {
|
||||
this.rememberLifecycleAuthorization(activation, identity, permissionSession.sessionId);
|
||||
}
|
||||
return freezeJson({
|
||||
pluginId: activation.plugin.id,
|
||||
pluginVersion: activation.plugin.activeVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
providerId: activation.provider.id,
|
||||
kind,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
async provide(params, options = {}) {
|
||||
const kind = assertTerminalProviderKind(params?.kind);
|
||||
const operation = assertBoundedString(params?.operation, "Terminal Provider operation", 128);
|
||||
const session = normalizeTerminalSessionSnapshot(params?.session);
|
||||
const providedPayload = params?.payload === undefined
|
||||
? undefined
|
||||
: assertBoundedJson(params.payload, "Provider payload");
|
||||
const payload = freezeJson(providedPayload && typeof providedPayload === "object" && !Array.isArray(providedPayload)
|
||||
? { ...providedPayload, session }
|
||||
: { session, ...(providedPayload === undefined ? {} : { value: providedPayload }) });
|
||||
assertBoundedJson(payload, "Provider request payload");
|
||||
try { assertTerminalProviderPayload(kind, operation, payload); }
|
||||
catch (error) { throw invalidArgument(error?.message ?? "Terminal Provider payload is invalid"); }
|
||||
const providers = this.listProviders({
|
||||
kind,
|
||||
locale: params?.locale,
|
||||
preferredProviderIds: params?.preferredProviderIds,
|
||||
}).slice(0, MAX_TERMINAL_PROVIDERS_PER_REQUEST);
|
||||
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
|
||||
return freezeJson(await Promise.all(providers.map(async (entry) => {
|
||||
const { provider } = entry;
|
||||
const requestId = `provider-${randomUUID()}`;
|
||||
try {
|
||||
return await this.invokeProvider({
|
||||
providerId: provider.id,
|
||||
kind,
|
||||
operation,
|
||||
requestId,
|
||||
payload,
|
||||
deadlineMs,
|
||||
}, options);
|
||||
} catch (error) {
|
||||
return freezeJson({
|
||||
pluginId: entry.pluginId,
|
||||
pluginVersion: entry.pluginVersion,
|
||||
providerId: provider.id,
|
||||
kind,
|
||||
...providerFailure(requestId, options.signal?.aborted
|
||||
? new PluginRpcError(RPC_ERRORS.cancelled, "Terminal Provider request was cancelled")
|
||||
: error),
|
||||
});
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
async publishSessionEvent(event) {
|
||||
const normalized = normalizeTerminalSessionEvent(event);
|
||||
const sessionId = normalized.session.sessionId;
|
||||
const pluginIds = this.activeTerminalProviderPluginIds();
|
||||
this.pruneLifecycleAuthorizations(pluginIds, sessionId);
|
||||
const deliveries = await Promise.all([...pluginIds].sort().map(async (pluginId) => {
|
||||
const key = this.lifecycleAuthorizationKey(pluginId, sessionId);
|
||||
const authorization = this.lifecycleAuthorizations.get(key);
|
||||
if (!authorization) return Object.freeze({ pluginId, delivered: false });
|
||||
const identity = this.runtimeSupervisor.getRuntimeIdentity(pluginId);
|
||||
if (!runtimeIdentityMatches(identity, authorization.identity)) {
|
||||
this.lifecycleAuthorizations.delete(key);
|
||||
return Object.freeze({ pluginId, delivered: false });
|
||||
}
|
||||
try {
|
||||
await this.permissionEngine.authorize(authorization.context, {
|
||||
permission: "provider.terminal",
|
||||
resources: ["*"],
|
||||
reason: "Receive Terminal Provider lifecycle metadata",
|
||||
operationId: `terminal.lifecycle:${sessionId}`,
|
||||
sessionId,
|
||||
interactive: false,
|
||||
});
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.terminal.event", normalized, {
|
||||
expectedIdentity: authorization.identity,
|
||||
});
|
||||
if (normalized.type === "disposed") this.lifecycleAuthorizations.delete(key);
|
||||
return Object.freeze({ pluginId, delivered: true });
|
||||
} catch {
|
||||
this.lifecycleAuthorizations.delete(key);
|
||||
return Object.freeze({ pluginId, delivered: false });
|
||||
}
|
||||
}));
|
||||
if (normalized.type === "disposed") {
|
||||
for (const [key, authorization] of this.lifecycleAuthorizations) {
|
||||
if (authorization.sessionId === sessionId) this.lifecycleAuthorizations.delete(key);
|
||||
}
|
||||
}
|
||||
return freezeJson(deliveries);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_PROVIDER_DEADLINE_MS,
|
||||
MAX_PROVIDER_JSON_BYTES,
|
||||
MAX_TERMINAL_PROVIDERS_PER_REQUEST,
|
||||
PluginTerminalProviderService,
|
||||
TERMINAL_PROVIDER_KINDS,
|
||||
TERMINAL_PROVIDER_PERMISSIONS,
|
||||
assertTerminalProviderKind,
|
||||
normalizeTerminalSessionEvent,
|
||||
normalizeTerminalSessionSnapshot,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user