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

This commit is contained in:
2026-10-15 13:53:04 +08:00
commit 2f9f8da48e
3299 changed files with 926841 additions and 0 deletions

View 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,
};

View 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);
});

View 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,
};

View 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();
});

View 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,
};

View 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,
};

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

View 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,
};

View 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,
};

View 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);
});

View 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,
};

View 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/);
});

View 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,
};

View 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"]);
});

View 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 };

View 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,
};

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

File diff suppressed because it is too large Load Diff

View 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();
});

View 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);
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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,
};

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

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

View File

@@ -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"]
}
}

View File

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

View File

@@ -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"]
}
}

File diff suppressed because it is too large Load Diff

View 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 };

View 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/);
});

View 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,
};

View 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");
});

View 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 };

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

View 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,
};

View 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));
});

View 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 };

View 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);
});

View 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,
};

View 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/);
}
});

View 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,
};

View 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);
});

View 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,
};

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

View 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,
};

View 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(), []);
});

View 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");
}
});

View 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,
};

View 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");
});

View 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,
};

View 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();
});

View 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,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 };

View 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]" });
});

View 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 };

View 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",
]);
});

View 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,
};

View 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);
});

View 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);
});

View 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,
};

View 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,
};

View 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 });
});

View 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 };

View 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",
}]);
});

View 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 };

View 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();
});

View 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,
};

View 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);
});

View 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 };

View 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/);
});

View 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");

View 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,
}, "*");

View 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);
}

View 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 };

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

View File

@@ -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);
});

View 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" });

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

View 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();
});

View 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,
};

View 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/);
});

View 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,
};

View 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,
};

View 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();
});

View 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 };

View 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");
});

View 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,
};

View 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/);
});

View 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 };

View 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);
});

View 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,
};

View 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);
});

View 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,
};

View 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);
});

View 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,
};

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

View 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