310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
|
|
"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");
|
||
|
|
});
|