934 lines
34 KiB
JavaScript
934 lines
34 KiB
JavaScript
|
|
"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);
|
||
|
|
});
|