mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 18:21:00 +00:00
* fix(plugins): address residual Codex sync secret and backup gaps Retain provider→plugin secret bindings so disconnect can wipe credentials after a plugin is disabled, keep overwritten secrets on failed reconnect, and snapshot live-empty sidecars in protective backups. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(plugins): restore overwritten sync secrets on rejected reconnect Stash prior plaintext and SecretRef id on overwrite, restore them when connect fails, and discard the stash after a successful reconnect so saved provider credentials keep resolving. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(plugins): make sync secret overwrite stash opt-in Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary secrets.set overwrites do not leave credentials in process memory. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(plugins): host-own sync provider bindings and clear failed stashes Move provider→plugin bindings into a dedicated DB table outside plugin-writable secrets, validate provider namespace ownership, and drop overwrite stashes when replacement writes fail. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(plugins): keep overwrite stash until restore write succeeds Only drop the prior plaintext after encrypt+upsert succeeds so a failed restore can still retry and recover the saved SecretRef. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(plugins): preserve overwrite stash across retries and prefix deletes Do not replace an existing restore stash on later puts, and clear matching stash entries when deleteByKeyPrefix wipes sync credentials on disconnect. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
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"]]);
|
|
});
|