1602 lines
58 KiB
JavaScript
1602 lines
58 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const { mkdtemp, rename, rm, writeFile } = require("node:fs/promises");
|
||
|
|
const { tmpdir } = require("node:os");
|
||
|
|
const { join } = require("node:path");
|
||
|
|
const test = require("node:test");
|
||
|
|
|
||
|
|
const {
|
||
|
|
CHANNELS,
|
||
|
|
createTrustedPluginBridgeSender,
|
||
|
|
registerPluginBridge,
|
||
|
|
normalizePluginScopeCatalog,
|
||
|
|
} = require("./pluginBridge.cjs");
|
||
|
|
|
||
|
|
function createIpcMain() {
|
||
|
|
const handlers = new Map();
|
||
|
|
const listeners = new Map();
|
||
|
|
return {
|
||
|
|
handlers,
|
||
|
|
listeners,
|
||
|
|
handle(channel, handler) { handlers.set(channel, handler); },
|
||
|
|
on(channel, listener) {
|
||
|
|
const list = listeners.get(channel) ?? [];
|
||
|
|
list.push(listener);
|
||
|
|
listeners.set(channel, list);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("plugin management bridge is unavailable unless the local development gate is explicit", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: null,
|
||
|
|
env: {},
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.status)({}), {
|
||
|
|
available: false,
|
||
|
|
experimental: true,
|
||
|
|
});
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.list)({}), /runtime is disabled/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("disabled plugin runtime keeps passive host integration channels quiet", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: null,
|
||
|
|
env: {},
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.terminalProviders)({}, {}), []);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.terminalSessionEvent)({}, {}), []);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.extensionProviders)({}, {}), []);
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.credentialCatalogUpdate)({}, { entries: [] }), 0);
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.setScopeCatalog)({}, {}), null);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.getScopeCatalog)({}, {}), {
|
||
|
|
workspace: [],
|
||
|
|
host: [],
|
||
|
|
session: [],
|
||
|
|
device: [{ id: "device", label: "This device" }],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin management bridge fails closed when the host manager is unavailable", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: null,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.status)({}), {
|
||
|
|
available: false,
|
||
|
|
experimental: true,
|
||
|
|
});
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.list)({}), /disabled or unavailable/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin management bridge checks sender ownership before invoking manager", async () => {
|
||
|
|
const calls = [];
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: {
|
||
|
|
initialize: async () => {},
|
||
|
|
list: async () => [],
|
||
|
|
install: async (...args) => calls.push(args),
|
||
|
|
setEnabled: async () => null,
|
||
|
|
restart: async () => null,
|
||
|
|
uninstall: async () => true,
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: "http://localhost:5173" }),
|
||
|
|
});
|
||
|
|
const trusted = { senderFrame: { url: "app://netcatty/index.html" } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.install)(trusted, { archivePath: "/plugin.ncpkg", enable: true });
|
||
|
|
assert.deepEqual(calls, [["/plugin.ncpkg", { enable: true }]]);
|
||
|
|
await assert.rejects(
|
||
|
|
ipcMain.handlers.get(CHANNELS.list)({ senderFrame: { url: "https://attacker.invalid/" } }),
|
||
|
|
/Untrusted/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin Vault credential catalog updates only through the trusted host bridge", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const updates = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
credentialResolver: {
|
||
|
|
update(entries) { updates.push(entries); return entries.length; },
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: (event) => event?.trusted === true,
|
||
|
|
});
|
||
|
|
const entries = [{ id: "credential-reference-0001", ciphertext: "enc:v1:Y2lwaGVy" }];
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.credentialCatalogUpdate)(
|
||
|
|
{ trusted: true },
|
||
|
|
{ entries },
|
||
|
|
), 1);
|
||
|
|
assert.deepEqual(updates, [entries]);
|
||
|
|
await assert.rejects(
|
||
|
|
ipcMain.handlers.get(CHANNELS.credentialCatalogUpdate)({ trusted: false }, { entries }),
|
||
|
|
/Untrusted/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin management availability follows asynchronous host initialization", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let listCalls = 0;
|
||
|
|
const initializationError = new Error("package recovery failed");
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: {
|
||
|
|
initialize: async () => { throw initializationError; },
|
||
|
|
list: async () => { listCalls += 1; return []; },
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.status)({}), {
|
||
|
|
available: false,
|
||
|
|
experimental: true,
|
||
|
|
});
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.list)({}), (error) => (
|
||
|
|
error.message.includes("disabled or unavailable") && error.cause === initializationError
|
||
|
|
));
|
||
|
|
assert.equal(listCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin view host closures are broadcast to renderer windows", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const broadcasts = [];
|
||
|
|
let closeListener;
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
contributionService: {},
|
||
|
|
viewHost: {
|
||
|
|
onDidClose(listener) { closeListener = listener; return { dispose() {} }; },
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
broadcast: (...args) => broadcasts.push(args),
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
instanceId: "view-1",
|
||
|
|
pluginId: "com.example.view",
|
||
|
|
viewId: "com.example.view.panel",
|
||
|
|
reason: "runtime-error",
|
||
|
|
};
|
||
|
|
closeListener(event);
|
||
|
|
assert.deepEqual(broadcasts, [[CHANNELS.viewClosed, event]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin contribution icon requests use the host-owned resolver", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const calls = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
resolveContributionIcon: async (payload) => {
|
||
|
|
calls.push(payload);
|
||
|
|
return { light: "data:image/png;base64,bGlnaHQ=" };
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const payload = {
|
||
|
|
pluginId: "com.example.icon",
|
||
|
|
icon: { kind: "package", light: "assets/icon.png" },
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.contributionIcon)({}, payload), {
|
||
|
|
light: "data:image/png;base64,bGlnaHQ=",
|
||
|
|
});
|
||
|
|
assert.deepEqual(calls, [payload]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin setting scope catalogs are bounded, sender-owned, and merged for settings windows", async () => {
|
||
|
|
assert.deepEqual(normalizePluginScopeCatalog({
|
||
|
|
host: [{ id: "host-1", label: "Production" }, { id: "host-1", label: "Duplicate" }],
|
||
|
|
workspace: [{ id: "", label: "Invalid" }],
|
||
|
|
}), {
|
||
|
|
workspace: [],
|
||
|
|
host: [{ id: "host-1", label: "Production" }],
|
||
|
|
session: [],
|
||
|
|
device: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const broadcasts = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
broadcast: (...args) => broadcasts.push(args),
|
||
|
|
});
|
||
|
|
let firstDestroyed;
|
||
|
|
const first = {
|
||
|
|
sender: {
|
||
|
|
id: 1,
|
||
|
|
once: (event, listener) => { if (event === "destroyed") firstDestroyed = listener; },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const second = { sender: { id: 2, once() {} } };
|
||
|
|
const settingsWindow = { sender: { id: 3, once() {} } };
|
||
|
|
const next = { host: [{ id: "host-1", label: "Production" }] };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.setScopeCatalog)(first, next);
|
||
|
|
await ipcMain.handlers.get(CHANNELS.setScopeCatalog)(second, {
|
||
|
|
workspace: [{ id: "workspace-2", label: "Second window" }],
|
||
|
|
host: [{ id: "host-1", label: "Duplicate from second window" }],
|
||
|
|
});
|
||
|
|
const merged = {
|
||
|
|
workspace: [{ id: "workspace-2", label: "Second window" }],
|
||
|
|
host: [{ id: "host-1", label: "Production" }],
|
||
|
|
session: [],
|
||
|
|
device: [{ id: "device", label: "This device" }],
|
||
|
|
};
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.getScopeCatalog)(first), merged);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.getScopeCatalog)(second), merged);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.getScopeCatalog)(settingsWindow), merged);
|
||
|
|
assert.deepEqual(broadcasts.at(-1), [CHANNELS.scopeCatalogChanged, merged]);
|
||
|
|
firstDestroyed();
|
||
|
|
const afterFirstWindowClosed = {
|
||
|
|
workspace: [{ id: "workspace-2", label: "Second window" }],
|
||
|
|
host: [{ id: "host-1", label: "Duplicate from second window" }],
|
||
|
|
session: [],
|
||
|
|
device: [{ id: "device", label: "This device" }],
|
||
|
|
};
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.getScopeCatalog)(settingsWindow), afterFirstWindowClosed);
|
||
|
|
assert.deepEqual(broadcasts.at(-1), [CHANNELS.scopeCatalogChanged, afterFirstWindowClosed]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin terminal Provider bridge owns cancellation by renderer sender", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const calls = [];
|
||
|
|
const pipelineCalls = [];
|
||
|
|
let destroyed;
|
||
|
|
const terminalProviderService = {
|
||
|
|
listProviders(options) {
|
||
|
|
calls.push(["list", options]);
|
||
|
|
return [{ provider: { id: "com.example.completion" } }];
|
||
|
|
},
|
||
|
|
async provide(request, options) {
|
||
|
|
calls.push(["provide", request]);
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
options.signal.addEventListener("abort", () => resolve([{ status: "cancelled" }]), { once: true });
|
||
|
|
});
|
||
|
|
},
|
||
|
|
async publishSessionEvent(event) {
|
||
|
|
calls.push(["event", event]);
|
||
|
|
return [{ pluginId: "com.example", delivered: true }];
|
||
|
|
},
|
||
|
|
};
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
terminalProviderService,
|
||
|
|
terminalDataPipelineService: {
|
||
|
|
async handleSessionEvent(payload, options) {
|
||
|
|
pipelineCalls.push([payload, options]);
|
||
|
|
return [{ direction: "input", attached: true }];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 42,
|
||
|
|
once(name, listener) { if (name === "destroyed") destroyed = listener; },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.terminalProviders)(event, {
|
||
|
|
kind: "terminal.completion",
|
||
|
|
}), [{ provider: { id: "com.example.completion" } }]);
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.terminalProvide)(event, {
|
||
|
|
requestId: "renderer-request-1",
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.terminalCancel)(event, {
|
||
|
|
requestId: "renderer-request-1",
|
||
|
|
}), true);
|
||
|
|
assert.deepEqual(await pending, [{ status: "cancelled" }]);
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.terminalCancel)(event, {
|
||
|
|
requestId: "renderer-request-1",
|
||
|
|
}), false);
|
||
|
|
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.terminalSessionEvent)(event, {
|
||
|
|
type: "created",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
}), [{ pluginId: "com.example", delivered: true }]);
|
||
|
|
assert.deepEqual(pipelineCalls, [[
|
||
|
|
{ type: "created", session: { sessionId: "session-1" } },
|
||
|
|
{ webContentsId: 42 },
|
||
|
|
]]);
|
||
|
|
assert.equal(typeof destroyed, "function");
|
||
|
|
const destroyedPending = ipcMain.handlers.get(CHANNELS.terminalProvide)(event, {
|
||
|
|
requestId: "renderer-request-destroyed",
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
destroyed();
|
||
|
|
assert.deepEqual(await destroyedPending, [{ status: "cancelled" }]);
|
||
|
|
assert.deepEqual(calls, [
|
||
|
|
["list", { kind: "terminal.completion" }],
|
||
|
|
["provide", {
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
}],
|
||
|
|
["event", { type: "created", session: { sessionId: "session-1" } }],
|
||
|
|
["provide", {
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
}],
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin terminal Provider bridge releases cancellation during host initialization", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let releaseInitialization;
|
||
|
|
let initializationStarted;
|
||
|
|
const started = new Promise((resolve) => { initializationStarted = resolve; });
|
||
|
|
const initialization = new Promise((resolve) => { releaseInitialization = resolve; });
|
||
|
|
const observedSignals = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: {
|
||
|
|
async initialize() {
|
||
|
|
initializationStarted();
|
||
|
|
await initialization;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
terminalProviderService: {
|
||
|
|
async provide(_request, options) {
|
||
|
|
observedSignals.push(options.signal);
|
||
|
|
return [{ status: options.signal.aborted ? "cancelled" : "ok" }];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 84, once() {} } };
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.terminalProvide)(event, {
|
||
|
|
requestId: "cancel-during-initialize",
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
});
|
||
|
|
await started;
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.terminalCancel)(event, {
|
||
|
|
requestId: "cancel-during-initialize",
|
||
|
|
}), true);
|
||
|
|
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||
|
|
assert.equal(observedSignals.length, 0);
|
||
|
|
releaseInitialization();
|
||
|
|
const retry = await ipcMain.handlers.get(CHANNELS.terminalProvide)(event, {
|
||
|
|
requestId: "after-cancelled-initialize",
|
||
|
|
kind: "terminal.completion",
|
||
|
|
operation: "provideCompletions",
|
||
|
|
session: { sessionId: "session-1" },
|
||
|
|
});
|
||
|
|
assert.deepEqual(retry, [{ status: "ok" }]);
|
||
|
|
assert.equal(observedSignals.length, 1);
|
||
|
|
assert.equal(observedSignals[0].aborted, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin extension Provider requests are cancellable and sender-owned", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let destroyed;
|
||
|
|
const calls = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async invoke(payload, options) {
|
||
|
|
calls.push(payload);
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
options.signal.addEventListener("abort", () => resolve({ cancelled: true }), { once: true });
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 73,
|
||
|
|
once(name, listener) { if (name === "destroyed") destroyed = listener; },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.extensionInvoke)(event, {
|
||
|
|
requestId: "extension-request-1",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
kind: "connection",
|
||
|
|
operation: "probe",
|
||
|
|
payload: {},
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.extensionCancel)(event, {
|
||
|
|
requestId: "extension-request-1",
|
||
|
|
}), true);
|
||
|
|
assert.deepEqual(await pending, { cancelled: true });
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.extensionCancel)(event, {
|
||
|
|
requestId: "extension-request-1",
|
||
|
|
}), false);
|
||
|
|
assert.equal(typeof destroyed, "function");
|
||
|
|
assert.equal(calls.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("generic extension invocation cannot bypass authentication or connection-session ownership", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let calls = 0;
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async invoke() { calls += 1; return null; },
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const invoke = ipcMain.handlers.get(CHANNELS.extensionInvoke);
|
||
|
|
const event = { sender: { once() {}, id: 41 } };
|
||
|
|
await assert.rejects(invoke(event, {
|
||
|
|
requestId: "auth-bypass",
|
||
|
|
kind: "authentication",
|
||
|
|
providerId: "com.example.auth.provider",
|
||
|
|
operation: "respond",
|
||
|
|
payload: { response: "plaintext" },
|
||
|
|
}), /dedicated host workflow/i);
|
||
|
|
await assert.rejects(invoke(event, {
|
||
|
|
requestId: "connection-bypass",
|
||
|
|
kind: "connection",
|
||
|
|
providerId: "com.example.connection.provider",
|
||
|
|
operation: "close",
|
||
|
|
payload: { connectionId: "not-owned" },
|
||
|
|
}), /dedicated host workflow/i);
|
||
|
|
assert.equal(calls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin importer files use sender-owned native selections and bounded streaming", async (context) => {
|
||
|
|
const directory = await mkdtemp(join(tmpdir(), "netcatty-plugin-importer-"));
|
||
|
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||
|
|
const filePath = join(directory, "hosts.json");
|
||
|
|
await writeFile(filePath, "streamed-import");
|
||
|
|
const parsed = [];
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async parseImporter(params, options) {
|
||
|
|
for await (const chunk of params.source) parsed.push(Buffer.from(chunk));
|
||
|
|
assert.equal(params.sourceByteLength, 15);
|
||
|
|
assert.equal(params.fileName, "hosts.json");
|
||
|
|
options.onProgress({ type: "progress", completed: 1, total: 2, message: "Reading" });
|
||
|
|
return { providerId: params.providerId, result: { parsed: 0, warnings: 0, errors: 0 }, records: [] };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
selectImporterFile: async () => filePath,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const progressEvents = [];
|
||
|
|
const first = { sender: {
|
||
|
|
id: 1,
|
||
|
|
once() {},
|
||
|
|
isDestroyed: () => false,
|
||
|
|
send(channel, payload) { progressEvents.push([channel, payload]); },
|
||
|
|
} };
|
||
|
|
const second = { sender: { id: 2, once() {} } };
|
||
|
|
const selection = await ipcMain.handlers.get(CHANNELS.importerSelectFile)(first, {});
|
||
|
|
assert.equal(selection.fileName, "hosts.json");
|
||
|
|
assert.equal(Buffer.from(selection.sample).toString(), "streamed-import");
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.importerParseFile)(second, {
|
||
|
|
requestId: "other-window",
|
||
|
|
providerId: "com.example.importer",
|
||
|
|
selectionToken: selection.selectionToken,
|
||
|
|
}), /selection expired/i);
|
||
|
|
await rename(filePath, `${filePath}.selected`);
|
||
|
|
await writeFile(filePath, "path-replaced!!");
|
||
|
|
const preview = await ipcMain.handlers.get(CHANNELS.importerParseFile)(first, {
|
||
|
|
requestId: "owner-window",
|
||
|
|
providerId: "com.example.importer",
|
||
|
|
selectionToken: selection.selectionToken,
|
||
|
|
});
|
||
|
|
assert.equal(preview.providerId, "com.example.importer");
|
||
|
|
assert.equal(Buffer.concat(parsed).toString(), "streamed-import");
|
||
|
|
assert.deepEqual(progressEvents, [[CHANNELS.importerProgress, {
|
||
|
|
requestId: "owner-window",
|
||
|
|
providerId: "com.example.importer",
|
||
|
|
progress: { type: "progress", completed: 1, total: 2, message: "Reading" },
|
||
|
|
}]]);
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.importerParseFile)(first, {
|
||
|
|
requestId: "replay",
|
||
|
|
providerId: "com.example.importer",
|
||
|
|
selectionToken: selection.selectionToken,
|
||
|
|
}), /selection expired/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection authentication uses host-rendered sender-owned challenges", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const sent = [];
|
||
|
|
const calls = [];
|
||
|
|
const external = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async authenticate(params, requestChallenge) {
|
||
|
|
calls.push(["authenticate", params]);
|
||
|
|
const response = await requestChallenge({
|
||
|
|
id: "password-1",
|
||
|
|
kind: "password",
|
||
|
|
title: "Password",
|
||
|
|
});
|
||
|
|
calls.push(["response", response]);
|
||
|
|
return {
|
||
|
|
status: "authenticated",
|
||
|
|
credential: { kind: "credential", id: "credential-after-auth" },
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async openConnection(params, options) {
|
||
|
|
calls.push(["open", params]);
|
||
|
|
await options.onData(Uint8Array.from([0xe2]));
|
||
|
|
await options.onData(Uint8Array.from([0x82, 0xac]));
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { external.push(["start", options]); return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput(sessionId, data) { external.push(["output", sessionId, data]); },
|
||
|
|
async finishExternalSession(sessionId, details) { external.push(["finish", sessionId, details]); },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 74,
|
||
|
|
once() {},
|
||
|
|
isDestroyed: () => false,
|
||
|
|
send(channel, payload) { sent.push([channel, payload]); },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-auth-1",
|
||
|
|
sessionId: "session-auth-1",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
authenticationProviderId: "com.example.transport.authentication",
|
||
|
|
configuration: { host: "example.test" },
|
||
|
|
hostLabel: "Example transport",
|
||
|
|
hostname: "example.test",
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
sessionLog: {
|
||
|
|
enabled: true,
|
||
|
|
directory: "/logs",
|
||
|
|
format: "html",
|
||
|
|
timestampsEnabled: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(sent.length, 1);
|
||
|
|
assert.equal(sent[0][0], CHANNELS.authenticationChallenge);
|
||
|
|
const challengeEvent = sent[0][1];
|
||
|
|
await assert.rejects(
|
||
|
|
ipcMain.handlers.get(CHANNELS.authenticationRespond)({ sender: { id: 75, once() {} } }, {
|
||
|
|
...challengeEvent,
|
||
|
|
challengeId: challengeEvent.challenge.id,
|
||
|
|
response: "stolen",
|
||
|
|
}),
|
||
|
|
/not owned/,
|
||
|
|
);
|
||
|
|
await ipcMain.handlers.get(CHANNELS.authenticationRespond)(event, {
|
||
|
|
requestId: challengeEvent.requestId,
|
||
|
|
challengeRequestId: challengeEvent.challengeRequestId,
|
||
|
|
challengeId: challengeEvent.challenge.id,
|
||
|
|
response: "secret answer",
|
||
|
|
});
|
||
|
|
assert.deepEqual(await pending, {
|
||
|
|
sessionId: "session-auth-1",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
status: "connected",
|
||
|
|
diagnostics: [],
|
||
|
|
});
|
||
|
|
assert.deepEqual(calls[0], ["authenticate", {
|
||
|
|
providerId: "com.example.transport.authentication",
|
||
|
|
connectionProviderId: "com.example.transport.connection",
|
||
|
|
configuration: { host: "example.test" },
|
||
|
|
}]);
|
||
|
|
assert.deepEqual(calls[1], ["response", "secret answer"]);
|
||
|
|
assert.deepEqual(calls[2][0], "open");
|
||
|
|
assert.deepEqual(calls[2][1].credential, {
|
||
|
|
kind: "credential",
|
||
|
|
id: "credential-after-auth",
|
||
|
|
});
|
||
|
|
assert.equal(external[0][0], "start");
|
||
|
|
assert.deepEqual({
|
||
|
|
sessionId: external[0][1].sessionId,
|
||
|
|
hostLabel: external[0][1].hostLabel,
|
||
|
|
hostname: external[0][1].hostname,
|
||
|
|
sessionLog: external[0][1].sessionLog,
|
||
|
|
}, {
|
||
|
|
sessionId: "session-auth-1",
|
||
|
|
hostLabel: "Example transport",
|
||
|
|
hostname: "example.test",
|
||
|
|
sessionLog: {
|
||
|
|
enabled: true,
|
||
|
|
directory: "/logs",
|
||
|
|
format: "html",
|
||
|
|
timestampsEnabled: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.deepEqual(external[1], ["output", "session-auth-1", "€"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin authentication cancellation removes the queued renderer challenge", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const sent = [];
|
||
|
|
const external = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async authenticate(_params, requestChallenge) {
|
||
|
|
await requestChallenge({
|
||
|
|
id: "password-cancel",
|
||
|
|
kind: "password",
|
||
|
|
title: "Password",
|
||
|
|
});
|
||
|
|
throw new Error("authentication should not continue after cancellation");
|
||
|
|
},
|
||
|
|
async openConnection() {
|
||
|
|
throw new Error("connection should not open after cancellation");
|
||
|
|
},
|
||
|
|
closeSessionLocal(sessionId) { external.push(["close-local", sessionId]); },
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { external.push(["start", options.sessionId]); return { sessionId: options.sessionId }; },
|
||
|
|
async finishExternalSession(sessionId, details) { external.push(["finish", sessionId, details.reason]); },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 76,
|
||
|
|
once() {},
|
||
|
|
isDestroyed: () => false,
|
||
|
|
send(channel, payload) { sent.push([channel, payload]); },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-auth-cancel-1",
|
||
|
|
sessionId: "session-auth-cancel-1",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
authenticationProviderId: "com.example.transport.authentication",
|
||
|
|
configuration: { host: "example.test" },
|
||
|
|
hostLabel: "Example transport",
|
||
|
|
hostname: "example.test",
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(sent.length, 1);
|
||
|
|
assert.equal(sent[0][0], CHANNELS.authenticationChallenge);
|
||
|
|
const challengeEvent = sent[0][1];
|
||
|
|
|
||
|
|
assert.equal(await ipcMain.handlers.get(CHANNELS.extensionCancel)(event, {
|
||
|
|
requestId: "connection-auth-cancel-1",
|
||
|
|
}), true);
|
||
|
|
await assert.rejects(pending, /abort|cancel/i);
|
||
|
|
|
||
|
|
assert.deepEqual(sent[1], [CHANNELS.authenticationChallenge, {
|
||
|
|
requestId: "connection-auth-cancel-1",
|
||
|
|
challengeRequestId: challengeEvent.challengeRequestId,
|
||
|
|
challengeId: "password-cancel",
|
||
|
|
cancelled: true,
|
||
|
|
}]);
|
||
|
|
assert.deepEqual(external.at(-1), ["finish", "session-auth-cancel-1", "error"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection status monitoring releases readiness then keeps polling later failures", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const statusCalls = [];
|
||
|
|
let resolveConnectedOutput;
|
||
|
|
const connectedOutput = new Promise((resolve) => { resolveConnectedOutput = resolve; });
|
||
|
|
let resolveFinished;
|
||
|
|
const finished = new Promise((resolve) => { resolveFinished = resolve; });
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connecting", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(sessionId, operation, payload, options) {
|
||
|
|
statusCalls.push([sessionId, operation, payload, options.signal.aborted]);
|
||
|
|
if (statusCalls.length === 1) return { status: "connecting" };
|
||
|
|
if (statusCalls.length === 2) return { status: "connected" };
|
||
|
|
return {
|
||
|
|
status: "error",
|
||
|
|
message: "Link lost after connect",
|
||
|
|
diagnostics: [{ severity: "warning", message: "Provider reported reconnect exhaustion" }],
|
||
|
|
};
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput(sessionId, data, meta) { resolveConnectedOutput([sessionId, data, meta]); },
|
||
|
|
async finishExternalSession(sessionId, details) { resolveFinished([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
connectionStatusPollMs: 0,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 77, once() {}, isDestroyed: () => false } };
|
||
|
|
const opened = await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-silent-start",
|
||
|
|
sessionId: "session-silent-start",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
assert.equal(opened.status, "connecting");
|
||
|
|
assert.deepEqual(await connectedOutput, [
|
||
|
|
"session-silent-start",
|
||
|
|
"",
|
||
|
|
{ pluginPipelineIngressBytes: 0, pluginConnectionReady: true },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(await finished, [
|
||
|
|
"session-silent-start",
|
||
|
|
{
|
||
|
|
reason: "error",
|
||
|
|
error: "Link lost after connect",
|
||
|
|
diagnostics: [{ severity: "warning", message: "Provider reported reconnect exhaustion" }],
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
assert.equal(statusCalls.length, 3);
|
||
|
|
assert.equal(statusCalls.every((call) => call[1] === "getStatus" && call[3] === false), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connections that open connected release silent terminal startup", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const output = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput(sessionId, data, meta) { output.push([sessionId, data, meta]); return true; },
|
||
|
|
async finishExternalSession() { return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 79, once() {}, isDestroyed: () => false } };
|
||
|
|
const opened = await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-silent-connected",
|
||
|
|
sessionId: "session-silent-connected",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
assert.equal(opened.status, "connected");
|
||
|
|
assert.deepEqual(output, [[
|
||
|
|
"session-silent-connected",
|
||
|
|
"",
|
||
|
|
{ pluginPipelineIngressBytes: 0, pluginConnectionReady: true },
|
||
|
|
]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connections that open connected continue status monitoring", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const statusCalls = [];
|
||
|
|
let resolveFinished;
|
||
|
|
const finished = new Promise((resolve) => { resolveFinished = resolve; });
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(sessionId, operation) {
|
||
|
|
statusCalls.push([sessionId, operation]);
|
||
|
|
return { status: "closed", message: "Provider closed" };
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput() { return true; },
|
||
|
|
async finishExternalSession(sessionId, details) { resolveFinished([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
connectionStatusPollMs: 0,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 81, once() {}, isDestroyed: () => false } };
|
||
|
|
const opened = await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-connected-monitored",
|
||
|
|
sessionId: "session-connected-monitored",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
assert.equal(opened.status, "connected");
|
||
|
|
assert.deepEqual(await finished, [
|
||
|
|
"session-connected-monitored",
|
||
|
|
{ reason: "closed", error: "Provider closed" },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(statusCalls, [["session-connected-monitored", "getStatus"]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an explicit slow close stops status monitoring before the Provider responds", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let releaseClose;
|
||
|
|
const closeGate = new Promise((resolve) => { releaseClose = resolve; });
|
||
|
|
let resolveCloseStarted;
|
||
|
|
const closeStarted = new Promise((resolve) => { resolveCloseStarted = resolve; });
|
||
|
|
const statusCalls = [];
|
||
|
|
const finished = [];
|
||
|
|
const externalSessions = new Map();
|
||
|
|
const owners = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(sessionId, operation) {
|
||
|
|
if (operation === "close") {
|
||
|
|
resolveCloseStarted();
|
||
|
|
await closeGate;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
statusCalls.push([sessionId, operation]);
|
||
|
|
throw new Error("Plugin connection session was not found");
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
if (externalSessions.has(options.sessionId)) throw new Error("external session already registered");
|
||
|
|
owners.push(options.ownerToken);
|
||
|
|
externalSessions.set(options.sessionId, options.ownerToken);
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async pushExternalOutput() { return true; },
|
||
|
|
async finishExternalSession(sessionId, details, owner) {
|
||
|
|
if (externalSessions.get(sessionId) !== owner) return false;
|
||
|
|
externalSessions.delete(sessionId);
|
||
|
|
finished.push([sessionId, details, owner]);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
connectionStatusPollMs: 5,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 89, once() {}, isDestroyed: () => false } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-explicit-slow-close",
|
||
|
|
sessionId: "session-explicit-slow-close",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
const closing = ipcMain.handlers.get(CHANNELS.connectionControl)(event, {
|
||
|
|
sessionId: "session-explicit-slow-close",
|
||
|
|
operation: "close",
|
||
|
|
payload: {},
|
||
|
|
});
|
||
|
|
await closeStarted;
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
|
|
|
||
|
|
assert.deepEqual(statusCalls, []);
|
||
|
|
assert.deepEqual(finished, [[
|
||
|
|
"session-explicit-slow-close",
|
||
|
|
{ reason: "closed" },
|
||
|
|
owners[0],
|
||
|
|
]]);
|
||
|
|
await assert.rejects(ipcMain.handlers.get(CHANNELS.connectionWrite)(event, {
|
||
|
|
sessionId: "session-explicit-slow-close",
|
||
|
|
data: "late input",
|
||
|
|
}), /not owned/i);
|
||
|
|
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-explicit-slow-close-replacement",
|
||
|
|
sessionId: "session-explicit-slow-close",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
assert.notEqual(owners[1], owners[0]);
|
||
|
|
assert.equal(externalSessions.get("session-explicit-slow-close"), owners[1]);
|
||
|
|
releaseClose();
|
||
|
|
assert.equal(await closing, null);
|
||
|
|
assert.equal(externalSessions.get("session-explicit-slow-close"), owners[1]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("silent connection readiness delivery failure closes the opened provider session", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const controls = [];
|
||
|
|
const finished = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(...args) { controls.push(args); return null; },
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput() { throw new Error("renderer unavailable"); },
|
||
|
|
async finishExternalSession(sessionId, details) { finished.push([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 80, once() {}, isDestroyed: () => false } };
|
||
|
|
await assert.rejects(
|
||
|
|
ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-readiness-failure",
|
||
|
|
sessionId: "session-readiness-failure",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
}),
|
||
|
|
/renderer unavailable/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(controls.map(([sessionId, operation]) => [sessionId, operation]), [
|
||
|
|
["session-readiness-failure", "close"],
|
||
|
|
]);
|
||
|
|
assert.deepEqual(finished, [[
|
||
|
|
"session-readiness-failure",
|
||
|
|
{ reason: "error", error: "renderer unavailable" },
|
||
|
|
]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection status monitoring closes asynchronous provider errors", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const closed = [];
|
||
|
|
const externalSessions = new Map();
|
||
|
|
const finishAttempts = [];
|
||
|
|
let providerOptions;
|
||
|
|
let resolveFinished;
|
||
|
|
const finished = new Promise((resolve) => { resolveFinished = resolve; });
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params, options) {
|
||
|
|
providerOptions = options;
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connecting", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control() {
|
||
|
|
return {
|
||
|
|
status: "error",
|
||
|
|
message: "Handshake rejected",
|
||
|
|
diagnostics: [{ severity: "error", message: "Host key mismatch", path: "configuration.hostKey" }],
|
||
|
|
};
|
||
|
|
},
|
||
|
|
closeSessionLocal(sessionId) {
|
||
|
|
closed.push(sessionId);
|
||
|
|
void providerOptions.onOutputClose(new Error("Plugin stream cancelled: status-close-repro"));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
externalSessions.set(options.sessionId, options.ownerToken);
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async pushExternalOutput() {},
|
||
|
|
async finishExternalSession(sessionId, details, owner) {
|
||
|
|
const accepted = externalSessions.get(sessionId) === owner;
|
||
|
|
finishAttempts.push([sessionId, details, accepted]);
|
||
|
|
if (!accepted) return false;
|
||
|
|
externalSessions.delete(sessionId);
|
||
|
|
resolveFinished([sessionId, details]);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
connectionStatusPollMs: 0,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 78, once() {}, isDestroyed: () => false } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-late-error",
|
||
|
|
sessionId: "session-late-error",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
assert.deepEqual(await finished, [
|
||
|
|
"session-late-error",
|
||
|
|
{
|
||
|
|
reason: "error",
|
||
|
|
error: "Handshake rejected",
|
||
|
|
diagnostics: [{ severity: "error", message: "Host key mismatch", path: "configuration.hostKey" }],
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.deepEqual(closed, ["session-late-error"]);
|
||
|
|
assert.equal(finishAttempts[0][1].error, "Handshake rejected");
|
||
|
|
assert.equal(finishAttempts[0][2], true);
|
||
|
|
assert.equal(finishAttempts[1][1].error, "Plugin stream cancelled: status-close-repro");
|
||
|
|
assert.equal(finishAttempts[1][2], false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection status monitoring invokes Provider reconnect for retryable failures", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const controls = [];
|
||
|
|
const outputs = [];
|
||
|
|
let statusIndex = 0;
|
||
|
|
let resolveFinished;
|
||
|
|
const finished = new Promise((resolve) => { resolveFinished = resolve; });
|
||
|
|
const statuses = [
|
||
|
|
{ status: "error", message: "Temporary loss", retryable: true },
|
||
|
|
{ status: "connected" },
|
||
|
|
{ status: "closed", message: "Done" },
|
||
|
|
];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connecting", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(sessionId, operation) {
|
||
|
|
controls.push([sessionId, operation]);
|
||
|
|
if (operation === "reconnect") return null;
|
||
|
|
return statuses[statusIndex++];
|
||
|
|
},
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput(sessionId, data, meta) { outputs.push([sessionId, data, meta]); },
|
||
|
|
async finishExternalSession(sessionId, details) { resolveFinished([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
connectionStatusPollMs: 0,
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 86, once() {}, isDestroyed: () => false } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-retryable-status",
|
||
|
|
sessionId: "session-retryable-status",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(await finished, [
|
||
|
|
"session-retryable-status",
|
||
|
|
{ reason: "closed", error: "Done" },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(controls, [
|
||
|
|
["session-retryable-status", "getStatus"],
|
||
|
|
["session-retryable-status", "reconnect"],
|
||
|
|
["session-retryable-status", "getStatus"],
|
||
|
|
["session-retryable-status", "getStatus"],
|
||
|
|
]);
|
||
|
|
assert.equal(outputs.length, 1);
|
||
|
|
assert.equal(outputs[0][2].pluginConnectionReady, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection output stream failures finish the terminal as errors", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let closeOutput;
|
||
|
|
const finished = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params, options) {
|
||
|
|
closeOutput = options.onOutputClose;
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control() { return { status: "connected" }; },
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput() { return true; },
|
||
|
|
async finishExternalSession(sessionId, details) { finished.push([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 82, once() {}, isDestroyed: () => false } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-output-failure",
|
||
|
|
sessionId: "session-output-failure",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
await closeOutput({ code: -32013, message: "Provider output failed" });
|
||
|
|
assert.deepEqual(finished.at(-1), [
|
||
|
|
"session-output-failure",
|
||
|
|
{ reason: "error", error: "Provider output failed" },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection output stream endings use supported terminal exit reasons", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let closeOutput;
|
||
|
|
const finished = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params, options) {
|
||
|
|
closeOutput = options.onOutputClose;
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control() { return { status: "connected" }; },
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) { return { sessionId: options.sessionId }; },
|
||
|
|
async pushExternalOutput() { return true; },
|
||
|
|
async finishExternalSession(sessionId, details) { finished.push([sessionId, details]); return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 90, once() {}, isDestroyed: () => false } };
|
||
|
|
const start = (requestId, sessionId) => ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId,
|
||
|
|
sessionId,
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
await start("connection-output-end", "session-output-end");
|
||
|
|
await closeOutput("end");
|
||
|
|
assert.deepEqual(finished.at(-1), [
|
||
|
|
"session-output-end",
|
||
|
|
{ reason: "exited", exitCode: 0 },
|
||
|
|
]);
|
||
|
|
|
||
|
|
await start("connection-output-cancel", "session-output-cancel");
|
||
|
|
await closeOutput("cancel");
|
||
|
|
assert.deepEqual(finished.at(-1), [
|
||
|
|
"session-output-cancel",
|
||
|
|
{ reason: "closed" },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("plugin connection ignores output that races with renderer session close", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let externalOptions;
|
||
|
|
let providerOptions;
|
||
|
|
let outputAttempts = 0;
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params, options) {
|
||
|
|
providerOptions = options;
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control() { return null; },
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
externalOptions = options;
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async pushExternalOutput(_sessionId, data) {
|
||
|
|
if (data === "") return true;
|
||
|
|
outputAttempts += 1;
|
||
|
|
throw new Error("session no longer exists");
|
||
|
|
},
|
||
|
|
async finishExternalSession() { return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 85, once() {}, isDestroyed: () => false } };
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-output-close-race",
|
||
|
|
sessionId: "session-output-close-race",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
await providerOptions.onData(Uint8Array.from([0xe2]));
|
||
|
|
await externalOptions.onClose("renderer-close");
|
||
|
|
await assert.doesNotReject(providerOptions.onData(new TextEncoder().encode("late output")));
|
||
|
|
await assert.doesNotReject(providerOptions.onOutputClose("end"));
|
||
|
|
assert.equal(outputAttempts, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("destroying a plugin connection owner closes the Provider and terminal worker session", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const destroyedListeners = [];
|
||
|
|
const controls = [];
|
||
|
|
const localCloses = [];
|
||
|
|
const finished = [];
|
||
|
|
let registeredOwner;
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params) {
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control(sessionId, operation, payload, options) {
|
||
|
|
controls.push([sessionId, operation, payload, options]);
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
closeSessionLocal(sessionId) { localCloses.push(sessionId); },
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
registeredOwner = options.ownerToken;
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async pushExternalOutput() { return true; },
|
||
|
|
async finishExternalSession(sessionId, details, owner) {
|
||
|
|
finished.push([sessionId, details, owner]);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 87,
|
||
|
|
once(name, listener) {
|
||
|
|
if (name === "destroyed") destroyedListeners.push(listener);
|
||
|
|
},
|
||
|
|
isDestroyed: () => false,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-owner-destroyed",
|
||
|
|
sessionId: "session-owner-destroyed",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const listener of destroyedListeners) listener();
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
|
||
|
|
assert.deepEqual(controls.map((call) => call.slice(0, 3)), [["session-owner-destroyed", "close", {}]]);
|
||
|
|
assert.equal(controls[0][3].sessionOwner, registeredOwner);
|
||
|
|
assert.deepEqual(localCloses, []);
|
||
|
|
assert.deepEqual(finished, [["session-owner-destroyed", { reason: "closed" }, registeredOwner]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a late Provider close cannot finish a same-ID replacement connection", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const providerOptions = [];
|
||
|
|
const externalSessions = new Map();
|
||
|
|
const owners = [];
|
||
|
|
const finishedOwners = [];
|
||
|
|
const writes = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async openConnection(params, options) {
|
||
|
|
providerOptions.push(options);
|
||
|
|
return { sessionId: params.sessionId, providerId: params.providerId, status: "connected", diagnostics: [] };
|
||
|
|
},
|
||
|
|
async control() { return null; },
|
||
|
|
async write(_sessionId, data, owner) { writes.push([data, owner]); },
|
||
|
|
closeSessionLocal() {},
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
if (externalSessions.has(options.sessionId)) throw new Error("external session already registered");
|
||
|
|
owners.push(options.ownerToken);
|
||
|
|
externalSessions.set(options.sessionId, { options, owner: options.ownerToken });
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async pushExternalOutput(sessionId, _data, _meta, owner) {
|
||
|
|
return externalSessions.get(sessionId)?.owner === owner;
|
||
|
|
},
|
||
|
|
async finishExternalSession(sessionId, _details, owner) {
|
||
|
|
if (externalSessions.get(sessionId)?.owner !== owner) return false;
|
||
|
|
externalSessions.delete(sessionId);
|
||
|
|
finishedOwners.push(owner);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 88, once() {}, isDestroyed: () => false } };
|
||
|
|
const start = (requestId) => ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId,
|
||
|
|
sessionId: "session-replaced",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
|
||
|
|
await start("connection-replaced-old");
|
||
|
|
const oldExternal = externalSessions.get("session-replaced");
|
||
|
|
externalSessions.delete("session-replaced");
|
||
|
|
await oldExternal.options.onClose("renderer-close");
|
||
|
|
await start("connection-replaced-new");
|
||
|
|
|
||
|
|
await assert.doesNotReject(providerOptions[0].onOutputClose("end"));
|
||
|
|
assert.equal(externalSessions.get("session-replaced")?.owner, owners[1]);
|
||
|
|
assert.deepEqual(finishedOwners, []);
|
||
|
|
await ipcMain.handlers.get(CHANNELS.connectionWrite)(event, {
|
||
|
|
sessionId: "session-replaced",
|
||
|
|
data: "new input",
|
||
|
|
});
|
||
|
|
assert.deepEqual(writes, [["new input", owners[1]]]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the terminal during plugin authentication cancels the challenge before provider open", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
const sent = [];
|
||
|
|
const closed = [];
|
||
|
|
let externalOptions;
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
async authenticate(_params, requestChallenge, options) {
|
||
|
|
await requestChallenge({
|
||
|
|
id: "password-before-open",
|
||
|
|
kind: "password",
|
||
|
|
title: "Password",
|
||
|
|
});
|
||
|
|
if (options.signal.aborted) throw options.signal.reason;
|
||
|
|
return { status: "authenticated", credential: { kind: "credential", id: "unused" } };
|
||
|
|
},
|
||
|
|
async openConnection() {
|
||
|
|
throw new Error("provider open should not run after terminal close");
|
||
|
|
},
|
||
|
|
closeSessionLocal(sessionId) { closed.push(sessionId); },
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
externalOptions = options;
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async finishExternalSession() { return true; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = {
|
||
|
|
sender: {
|
||
|
|
id: 83,
|
||
|
|
once() {},
|
||
|
|
isDestroyed: () => false,
|
||
|
|
send(channel, payload) { sent.push([channel, payload]); },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-auth-close-before-open",
|
||
|
|
sessionId: "session-auth-close-before-open",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
authenticationProviderId: "com.example.transport.authentication",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(sent.length, 1);
|
||
|
|
await externalOptions.onClose("renderer-close");
|
||
|
|
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||
|
|
await assert.rejects(
|
||
|
|
ipcMain.handlers.get(CHANNELS.authenticationRespond)(event, {
|
||
|
|
requestId: sent[0][1].requestId,
|
||
|
|
challengeRequestId: sent[0][1].challengeRequestId,
|
||
|
|
challengeId: sent[0][1].challenge.id,
|
||
|
|
response: "too late",
|
||
|
|
}),
|
||
|
|
/not owned/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(closed, ["session-auth-close-before-open"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the terminal while a plugin connection opens cancels the provider startup", async () => {
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
let externalOptions;
|
||
|
|
const closed = [];
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { initialize: async () => {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
openConnection(_params, options) {
|
||
|
|
return new Promise((_resolve, reject) => {
|
||
|
|
const rejectCancelled = () => reject(options.signal.reason);
|
||
|
|
if (options.signal.aborted) rejectCancelled();
|
||
|
|
else options.signal.addEventListener("abort", rejectCancelled, { once: true });
|
||
|
|
});
|
||
|
|
},
|
||
|
|
closeSessionLocal(sessionId) { closed.push(sessionId); },
|
||
|
|
},
|
||
|
|
getTerminalWorkerManager: () => ({
|
||
|
|
async startExternalSession(options) {
|
||
|
|
externalOptions = options;
|
||
|
|
return { sessionId: options.sessionId };
|
||
|
|
},
|
||
|
|
async finishExternalSession() { return false; },
|
||
|
|
}),
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 76, once() {}, isDestroyed: () => false } };
|
||
|
|
const pending = ipcMain.handlers.get(CHANNELS.connectionStart)(event, {
|
||
|
|
requestId: "connection-close-during-start",
|
||
|
|
sessionId: "session-close-during-start",
|
||
|
|
providerId: "com.example.transport.connection",
|
||
|
|
configuration: {},
|
||
|
|
columns: 80,
|
||
|
|
rows: 24,
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
await externalOptions.onClose("renderer-close");
|
||
|
|
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||
|
|
assert.deepEqual(closed, ["session-close-during-start"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("sync read/write shuttle Uint8Array inline and stream large writes", async () => {
|
||
|
|
const written = [];
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { async initialize() {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
listProviders() {
|
||
|
|
return [{
|
||
|
|
pluginId: "com.example",
|
||
|
|
pluginVersion: "1.0.0",
|
||
|
|
provider: { id: "com.example.sync", kind: "sync", label: "Example Sync" },
|
||
|
|
}];
|
||
|
|
},
|
||
|
|
async readSyncObject(params) {
|
||
|
|
return {
|
||
|
|
found: true,
|
||
|
|
key: params.key,
|
||
|
|
bytes: Buffer.from("cipher-inline"),
|
||
|
|
revision: "r1",
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async writeSyncObject(params) {
|
||
|
|
written.push({
|
||
|
|
key: params.key,
|
||
|
|
byteLength: params.bytes.byteLength,
|
||
|
|
preferStream: params.preferStream === true,
|
||
|
|
});
|
||
|
|
return { created: true, revision: "r2" };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
secretStore: {
|
||
|
|
set(pluginId, key, value) {
|
||
|
|
return Object.freeze({ kind: "secret", id: "sec-1", key, pluginId, value });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
|
||
|
|
|
||
|
|
const inlineRead = await ipcMain.handlers.get(CHANNELS.syncReadObject)(event, {
|
||
|
|
requestId: "sync-read-1",
|
||
|
|
providerId: "com.example.sync",
|
||
|
|
key: "vault",
|
||
|
|
});
|
||
|
|
assert.equal(inlineRead.found, true);
|
||
|
|
assert.equal(inlineRead.streamed, undefined);
|
||
|
|
assert.ok(inlineRead.data instanceof Uint8Array || Buffer.isBuffer(inlineRead.data));
|
||
|
|
assert.equal(Buffer.from(inlineRead.data).toString("utf8"), "cipher-inline");
|
||
|
|
|
||
|
|
const inlineWrite = await ipcMain.handlers.get(CHANNELS.syncWriteObject)(event, {
|
||
|
|
requestId: "sync-write-1",
|
||
|
|
providerId: "com.example.sync",
|
||
|
|
key: "vault",
|
||
|
|
data: Buffer.from("small"),
|
||
|
|
});
|
||
|
|
assert.deepEqual(inlineWrite, { created: true, revision: "r2" });
|
||
|
|
|
||
|
|
const begin = await ipcMain.handlers.get(CHANNELS.syncWriteBegin)(event, {
|
||
|
|
requestId: "sync-write-stream",
|
||
|
|
providerId: "com.example.sync",
|
||
|
|
key: "vault",
|
||
|
|
byteLength: 5,
|
||
|
|
});
|
||
|
|
assert.equal(typeof begin.transferId, "string");
|
||
|
|
await ipcMain.handlers.get(CHANNELS.syncWriteChunk)(event, {
|
||
|
|
requestId: "sync-write-stream",
|
||
|
|
transferId: begin.transferId,
|
||
|
|
sequence: 0,
|
||
|
|
chunk: Buffer.from("large"),
|
||
|
|
});
|
||
|
|
const committed = await ipcMain.handlers.get(CHANNELS.syncWriteCommit)(event, {
|
||
|
|
requestId: "sync-write-stream",
|
||
|
|
transferId: begin.transferId,
|
||
|
|
});
|
||
|
|
assert.deepEqual(committed, { created: true, revision: "r2" });
|
||
|
|
assert.equal(written.at(-1)?.preferStream, true);
|
||
|
|
assert.equal(written.at(-1)?.byteLength, 5);
|
||
|
|
|
||
|
|
const secret = await ipcMain.handlers.get(CHANNELS.syncPutSecret)(event, {
|
||
|
|
providerId: "com.example.sync",
|
||
|
|
key: "sync-credential",
|
||
|
|
value: "pw",
|
||
|
|
});
|
||
|
|
assert.deepEqual(secret, {
|
||
|
|
kind: "secret",
|
||
|
|
id: "sec-1",
|
||
|
|
key: "sync-credential",
|
||
|
|
pluginId: "com.example",
|
||
|
|
value: "pw",
|
||
|
|
created: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("syncDeleteSecrets uses retained provider binding when contribution is missing", async () => {
|
||
|
|
const deleted = [];
|
||
|
|
const unbound = [];
|
||
|
|
const ipcMain = createIpcMain();
|
||
|
|
registerPluginBridge(ipcMain, {
|
||
|
|
manager: { async initialize() {} },
|
||
|
|
extensionProviderService: {
|
||
|
|
listProviders() {
|
||
|
|
return [];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
secretStore: {
|
||
|
|
set() {
|
||
|
|
throw new Error("set should not run");
|
||
|
|
},
|
||
|
|
delete(pluginId, key) {
|
||
|
|
deleted.push([pluginId, key]);
|
||
|
|
},
|
||
|
|
deleteByKeyPrefix(pluginId, prefix) {
|
||
|
|
deleted.push([pluginId, `prefix:${prefix}`]);
|
||
|
|
return 2;
|
||
|
|
},
|
||
|
|
resolveSyncProviderPlugin(providerId) {
|
||
|
|
assert.equal(providerId, "com.example.sync");
|
||
|
|
return "com.example";
|
||
|
|
},
|
||
|
|
unbindSyncProviderPlugin(pluginId, providerId) {
|
||
|
|
unbound.push([pluginId, providerId]);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||
|
|
isTrustedSender: () => true,
|
||
|
|
});
|
||
|
|
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
|
||
|
|
const result = await ipcMain.handlers.get(CHANNELS.syncDeleteSecrets)(event, {
|
||
|
|
providerId: "com.example.sync",
|
||
|
|
});
|
||
|
|
assert.deepEqual(result, { deleted: 2 });
|
||
|
|
assert.deepEqual(deleted, [["com.example", "prefix:sync-credential"]]);
|
||
|
|
assert.deepEqual(unbound, [["com.example", "com.example.sync"]]);
|
||
|
|
});
|