[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
309
electron/plugins/hostRpcRegistry.test.cjs
Normal file
309
electron/plugins/hostRpcRegistry.test.cjs
Normal file
@@ -0,0 +1,309 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
PluginHostRpcRegistry,
|
||||
assertHostMethod,
|
||||
cloneAndFreezeMetadata,
|
||||
} = require("./hostRpcRegistry.cjs");
|
||||
|
||||
function identity(overrides = {}) {
|
||||
return {
|
||||
pluginId: "com.example.registry",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("host RPC registry separates requests from notifications and rejects duplicate ownership", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("settings.get", async (params, context) => ({
|
||||
params,
|
||||
kind: context.kind,
|
||||
pluginId: context.pluginId,
|
||||
}));
|
||||
assert.throws(
|
||||
() => registry.registerNotification("settings.get", () => {}),
|
||||
/already registered/,
|
||||
);
|
||||
registry.registerNotification("telemetry.write", () => undefined);
|
||||
const routes = registry.createRoutes(identity());
|
||||
assert.deepEqual(
|
||||
await routes.requestHandlers["settings.get"]({ key: "theme" }, { signal: AbortSignal.timeout(10) }),
|
||||
{ params: { key: "theme" }, kind: "request", pluginId: "com.example.registry" },
|
||||
);
|
||||
assert.equal(routes.notificationHandlers["settings.get"], undefined);
|
||||
assert.equal(routes.requestHandlers["telemetry.write"], undefined);
|
||||
});
|
||||
|
||||
test("host RPC middleware receives immutable host identity, method metadata, and transport context", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
const metadata = { permission: { name: "vault.metadata", resources: ["hosts"] } };
|
||||
registry.use(async (context, next) => {
|
||||
seen.push(context);
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("vault.read", async (_params, context) => context.runtimeId, {
|
||||
metadata,
|
||||
});
|
||||
metadata.permission.name = "spoofed";
|
||||
metadata.permission.resources.push("credentials");
|
||||
const routes = registry.createRoutes(identity());
|
||||
const signal = AbortSignal.timeout(100);
|
||||
assert.equal(await routes.requestHandlers["vault.read"](
|
||||
{ hostId: "host-1" },
|
||||
{
|
||||
cancellationId: "cancel-1",
|
||||
deadlineMs: 100,
|
||||
pluginId: "com.attacker.spoofed",
|
||||
requestId: 7,
|
||||
runtimeId: "spoofed-runtime",
|
||||
signal,
|
||||
},
|
||||
), "runtime-1");
|
||||
assert.equal(Object.isFrozen(seen[0]), true);
|
||||
assert.equal(seen[0].pluginVersion, "1.0.0");
|
||||
assert.equal(seen[0].pluginId, "com.example.registry");
|
||||
assert.equal(seen[0].runtimeId, "runtime-1");
|
||||
assert.equal(seen[0].method, "vault.read");
|
||||
assert.deepEqual(seen[0].metadata.permission, { name: "vault.metadata", resources: ["hosts"] });
|
||||
assert.equal(Object.isFrozen(seen[0].metadata.permission), true);
|
||||
assert.equal(Object.isFrozen(seen[0].metadata.permission.resources), true);
|
||||
assert.deepEqual(seen[0].params, { hostId: "host-1" });
|
||||
assert.equal(seen[0].requestId, 7);
|
||||
assert.equal(seen[0].signal, signal);
|
||||
assert.equal(typeof seen[0].assertActive, "function");
|
||||
});
|
||||
|
||||
test("method parameters are validated and normalized before capability middleware", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
registry.use(async (context, next) => {
|
||||
seen.push(context.params);
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("filesystem.stat", async (params) => params, {
|
||||
validateParams(params) {
|
||||
if (typeof params?.path !== "string") throw new TypeError("path is required");
|
||||
return { path: params.path.normalize("NFC") };
|
||||
},
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
const normalized = await routes.requestHandlers["filesystem.stat"](
|
||||
{ path: "cafe\u0301" },
|
||||
{ signal: AbortSignal.timeout(100) },
|
||||
);
|
||||
assert.deepEqual(normalized, { path: "caf\u00e9" });
|
||||
assert.deepEqual(seen, [{ path: "caf\u00e9" }]);
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["filesystem.stat"]({}, { signal: AbortSignal.timeout(100) }),
|
||||
/path is required/,
|
||||
);
|
||||
assert.equal(seen.length, 1);
|
||||
});
|
||||
|
||||
test("parameter validators must be synchronous and metadata cloning cannot invoke __proto__", async () => {
|
||||
const cloned = cloneAndFreezeMetadata(JSON.parse('{"__proto__":{"polluted":true}}'));
|
||||
assert.equal(Object.getPrototypeOf(cloned), Object.prototype);
|
||||
assert.equal(Object.hasOwn(cloned, "__proto__"), true);
|
||||
assert.equal(cloned.__proto__.polluted, true);
|
||||
assert.equal({}.polluted, undefined);
|
||||
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("network.fetch", () => null, {
|
||||
validateParams: async (params) => params,
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["network.fetch"]({}, { signal: AbortSignal.timeout(100) }),
|
||||
/must be synchronous/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host RPC identity is rechecked after asynchronous middleware and handler work", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releaseMiddleware;
|
||||
let handlerCalls = 0;
|
||||
registry.use(async (_context, next) => {
|
||||
await new Promise((resolve) => { releaseMiddleware = resolve; });
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("vault.write", async () => {
|
||||
handlerCalls += 1;
|
||||
return null;
|
||||
});
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["vault.write"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releaseMiddleware();
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
assert.equal(handlerCalls, 0);
|
||||
});
|
||||
|
||||
test("host RPC results are discarded when identity becomes stale during handler work", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releaseHandler;
|
||||
registry.registerRequest("vault.read", async () => new Promise((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
}));
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["vault.read"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releaseHandler({ secret: "stale" });
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
});
|
||||
|
||||
test("async capability handlers can recheck activation immediately before committing", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let current = true;
|
||||
let releasePreparation;
|
||||
let commits = 0;
|
||||
registry.registerRequest("settings.set", async (_params, context) => {
|
||||
await new Promise((resolve) => { releasePreparation = resolve; });
|
||||
await context.assertActive();
|
||||
commits += 1;
|
||||
return null;
|
||||
});
|
||||
const routes = registry.createRoutes(identity({
|
||||
assertCurrent() {
|
||||
if (!current) throw new Error("stale activation");
|
||||
},
|
||||
}));
|
||||
const pending = routes.requestHandlers["settings.set"]({}, { signal: AbortSignal.timeout(100) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
current = false;
|
||||
releasePreparation();
|
||||
await assert.rejects(pending, /stale activation/);
|
||||
assert.equal(commits, 0);
|
||||
});
|
||||
|
||||
test("capability commit guards reject requests cancelled while handler work is pending", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseWork;
|
||||
const work = new Promise((resolve) => { releaseWork = resolve; });
|
||||
let committed = false;
|
||||
registry.registerRequest("filesystem.write", async (_params, context) => {
|
||||
await work;
|
||||
await context.assertActive();
|
||||
committed = true;
|
||||
return null;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: "com.example.cancelled",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-cancelled",
|
||||
runtimeKind: "utility",
|
||||
});
|
||||
const pending = routes.requestHandlers["filesystem.write"]({}, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("request cancelled"));
|
||||
releaseWork();
|
||||
await assert.rejects(pending, /request cancelled/);
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("cancelled middleware cannot enter a privileged capability handler", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseMiddleware;
|
||||
const middlewareWork = new Promise((resolve) => { releaseMiddleware = resolve; });
|
||||
let invoked = false;
|
||||
registry.use(async (_context, next) => {
|
||||
await middlewareWork;
|
||||
return next();
|
||||
});
|
||||
registry.registerRequest("network.fetch", async () => {
|
||||
invoked = true;
|
||||
return null;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: "com.example.cancelled",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-cancelled",
|
||||
runtimeKind: "utility",
|
||||
});
|
||||
const pending = routes.requestHandlers["network.fetch"]({}, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("approval cancelled"));
|
||||
releaseMiddleware();
|
||||
await assert.rejects(pending, /approval cancelled/);
|
||||
assert.equal(invoked, false);
|
||||
});
|
||||
|
||||
test("host RPC route snapshots remain stable while later registrations apply after restart", () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.registerRequest("first.call", () => null);
|
||||
const first = registry.createRoutes(identity());
|
||||
registry.registerRequest("second.call", () => null);
|
||||
const second = registry.createRoutes(identity({ runtimeId: "runtime-2" }));
|
||||
assert.equal(first.requestHandlers["second.call"], undefined);
|
||||
assert.equal(typeof second.requestHandlers["second.call"], "function");
|
||||
assert.ok(second.revision > first.revision);
|
||||
});
|
||||
|
||||
test("incoming streams route to the first owner and expose a host-assigned runtime identity", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const seen = [];
|
||||
registry.registerIncomingStream(async (stream, context) => {
|
||||
seen.push([stream.streamId, context.runtimeId]);
|
||||
return false;
|
||||
});
|
||||
registry.registerIncomingStream(async (stream) => stream.streamId === "owned");
|
||||
const routes = registry.createRoutes(identity());
|
||||
assert.equal(await routes.onIncomingStream({ streamId: "owned" }), true);
|
||||
assert.equal(await routes.onIncomingStream({ streamId: "unknown" }), false);
|
||||
assert.deepEqual(seen, [["owned", "runtime-1"], ["unknown", "runtime-1"]]);
|
||||
});
|
||||
|
||||
test("incoming stream owners receive cancellation through the shared active guard", async () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
let releaseOwner;
|
||||
const ownerWork = new Promise((resolve) => { releaseOwner = resolve; });
|
||||
let committed = false;
|
||||
registry.registerIncomingStream(async (_stream, context) => {
|
||||
await ownerWork;
|
||||
await context.assertActive();
|
||||
committed = true;
|
||||
return true;
|
||||
});
|
||||
const routes = registry.createRoutes(identity());
|
||||
const controller = new AbortController();
|
||||
const pending = routes.onIncomingStream({
|
||||
streamId: "cancelled",
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("stream owner cancelled"));
|
||||
releaseOwner();
|
||||
|
||||
await assert.rejects(pending, /stream owner cancelled/);
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("reserved lifecycle and transport methods cannot be claimed as host capabilities", () => {
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
for (const method of ["plugin.initialize", "plugin.activate", "plugin.deactivate", "$/progress"]) {
|
||||
assert.throws(() => assertHostMethod(method), /reserved/);
|
||||
assert.throws(() => registry.registerRequest(method, () => null), /reserved/);
|
||||
}
|
||||
assert.equal(assertHostMethod("commands.execute"), "commands.execute");
|
||||
});
|
||||
Reference in New Issue
Block a user