mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 10:14:20 +00:00
* feat(plugins): add sync providers and encrypted sidecar sync
Implement PR 8 of the plugin platform (#2269): namespaced sync Providers
with provider.sync, encrypted-object storage operations, WebDAV through the
shared storage surface, dynamic cloud provider IDs, and non-cascade sidecars
for sync:true plugin settings plus account/CRDT baselines.
Related to #2269
* fix(plugins): wire sync providers and sidecars into production paths
Close PR 8 integration gaps: dynamic plugin provider registry and
getConnectedAdapter createPluginStorage, WebDAV through EncryptedObjectStorage,
cloud payload collect/apply for pluginSidecars, and IPC/preload bridges.
Related to #2269
* fix(plugins): preserve WebDAV auth reuse and resourceId through EOS wrap
encryptedObjectStorageAsCloudAdapter now reports initiallyAuthenticated when
config exists (matching raw WebDAVAdapter) so getConnectedAdapter reuses the
cached instance, and preserves/refreshes resourceId from the backing adapter
instead of always forcing the default object key.
Related to #2269
* fix(plugins): address Codex PR 8 review findings
Carry pluginSidecars through merge/convergent uploads, lazy-connect before
object I/O with revision-aware writes, fail closed on sidecar collect errors,
expose connectPluginProvider, preserve remote timestamps, and drop deleted
installed-plugin settings from collection.
Related to #2269
* fix(plugins): close remaining Codex P1/P2 sync issues
Lazy stream cancel for inline sync reads, must-not-exist conditional writes,
three-way sidecar merge for local resets, validated apply path for settings,
and merge remote sidecars into convergent uploads.
Related to #2269
* fix(plugins): harden sidecar collect/apply and write verification
Preserve last-known sidecars when the host is offline, apply remote
deletions for installed plugins, force convergent upload on sidecar
divergence, verify plugin object writes, and pick plugin providers in
auto-sync startup checks.
Related to #2269
* fix(plugins): close remaining Codex sidecar/sync path gaps
Include sidecars in auto-sync payload and hash, keep explicit empty bundles,
fail apply when host is offline, require getCapabilities, and carry merged
sidecars into convergent local apply/upload decisions.
Related to #2269
* fix(plugins): close Codex P1 sidecar collect/apply/merge gaps
Treat null host responses as unavailable, persist collected settings into
the non-cascade table, retain decoded convergent payloads, use three-way
sidecar merge for deletions, and fail apply only on operational errors.
Also treat non-empty plugin sidecars as meaningful sync data and re-run
auto-sync when plugin contributions change.
Related to #2269
* fix(plugins): close remaining Codex P1 sidecar and provider gaps
Replay offline-cached sidecars before collect, keep explicit empty
sidecar fields after three-way merge, thread sidecars through convergent
conflict/downgrade materialization, keep gated-off plugin providers from
joining sync as connected, and rebind plugin EOS sessions after runtime
replacement.
Related to #2269
* fix(plugins): separate pending remote sidecars from last-known cache
Replay only host-offline remote applies into the DB, keep last-known as
upload fallback, use contribution reset for deleted settings, probe real
plugin host readiness, rebind storage events for plugin providers, clear
plugin bases on reset, and swallow disconnect rejections.
Related to #2269
* fix(plugins): hydrate retained sidecars and gate missing providers
Materialize installed-plugin settings from retained sidecars before
collect pruning, clear sidecars on setting reset, fail closed when
pending remote replay fails, and only restore plugin providers that are
contribution-available. Also initialize dynamic provider counters on
connect.
Related to #2269
* fix(plugins): surface plugin sync providers and preserve conflict sidecars
Collect live sidecars before convergent conflict apply/upload, include
dynamic providers in status and disconnect paths, and list plugin sync
providers in Cloud Sync settings for connect/disconnect.
Related to #2269
* fix(plugins): per-provider sidecar baselines and safer collect/UI
Merge convergent sidecars against each provider's own baseline, keep
retained settings in sidecars without unvalidated plugin_settings writes,
guard isConnectDisabled for never-connected plugins, and fall back plugin
provider labels/icons in the status popup.
Related to #2269
* fix(plugins): correct plugin provider discovery and sidecar reverify
Read nested contribution metadata for sync provider cards, gate empty-config
connects, enforce single-provider disconnect for plugins, and re-verify after
sidecar-only convergent uploads so baselines track the post-write payload.
Related to #2269
* fix(plugins): refresh sync provider availability from live contributions
Replace the contribution-available provider ID set when plugins change so
disabled or uninstalled sync providers leave the auto-sync ready set and
drop to disconnected with config retained.
Related to #2269
* fix(plugins): preserve sidecars in backups, pending queue, and migration
Attach last-known plugin sidecars to protective local vault backups, fail
operationally when pending/last-known storage writes are rejected, carry
sidecars through convergent migration publish, and reject trailing garbage
on encrypted object parse.
Related to #2269
* fix(plugins): abort conflict collect failures and merge migration sidecars
Rethrow operational sidecar collection errors during convergent conflict
resolution, LWW-union all local/provider sidecars during convergent
migration, re-collect last-known after apply, and prefer live host collect
for protective local vault backups.
Related to #2269
* fix(plugins): restore pluginBridge tests, fix import path, union downgrade sidecars
Correct SettingsSyncTab dynamic import path so production builds resolve,
add ipcMain.on to the plugin bridge test double, union all provider
sidecars on convergent downgrade, keep empty last-known resets, and skip
force-delete when contribution reset validation rejects.
Related to #2269
* fix(plugins): abort backup collect failures and honor empty remote wipes
Only fall back to last-known sidecars when the host is unavailable during
protective backups, treat explicit empty remote sidecar bundles as
authoritative wipes, remove lint-blocking anys from harness tests, and
use a stable equal-timestamp sidecar tie-break.
Related to #2269
* fix(plugins): include sidecars in equality, preserve merge base on reconnect
Compare pluginSidecars in cloudSyncPayloadsEqual for migration freshness,
clear plugin merge state only when account/resource changes on reconnect,
cancel accepted sync read streams on failure, and stop re-connecting the
plugin EOS adapter on every I/O.
Related to #2269
* fix(plugins): drop cached adapters on contribution refresh
Invalidate plugin sync adapters when live contributions change so a
restarted runtime gets a fresh connect, take offline any non-disconnected
missing providers, and latch version-change backups only after cancellation
checks pass.
Related to #2269
* fix(plugins): preserve omitted sidecars and hydrate on plugin enable
Treat missing pluginSidecars as legacy/unsupported in three-way merge
(only explicit empty is a wipe), hydrate retained settings when a plugin
enables, and re-enable retained provider configs when contributions return.
Related to #2269
* fix(plugins): keep newer retained sidecars over older stored settings
When collection merges existing sidecars with plugin_settings rows, prefer
the newer updatedAt so a schema-rejected remote value retained only in
the sidecar table is not overwritten by a stale local setting.
Related to #2269
* fix(plugins): seal plugin provider config and clear base on config change
Encrypt opaque plugin provider configuration before persistence, clear
merge state when reconnect configuration changes, and stream sync objects
below a base64-safe inline threshold so control-plane JSON stays in budget.
Related to #2269
* fix(plugins): seal plugin configs by provider id and hydrate before start
Encrypt plugin provider configuration only for non-builtin provider IDs
so field-name collisions cannot skip sealing, hydrate retained sidecars
before plugin enable starts the runtime, and stop tearing down live
adapters on no-op availability refreshes.
Related to #2269
* fix(plugins): seal scalar configs, safe inline reads, stable config fingerprint
Encrypt any non-builtin plugin configuration JSON shape, enforce the
base64-safe inline read cutoff, and compare reconnect configurations with
order-independent fingerprints so merge bases are not cleared spuriously.
Related to #2269
* fix(plugins): thread sync credentials and harden availability checks
Pass SyncConnectPayload.credential through the object-storage and IPC host
path, treat only null/undefined config as missing, and avoid wiping the
plugin provider availability catalog on transient discovery failures.
Related to #2269
* fix(plugins): unblock schema-required sync connect and scalar configs
Add a JSON config dialog for plugin sync providers that declare a
required configurationSchema, reuse retained configs on reconnect, and
treat falsy scalar configs as present in readiness and adapter lookup.
Related to #2269
* fix(plugins): rebind sync adapters on contribution refresh
Drop cached plugin sync adapters when live contributions still include
the provider so replaced runtimes get a fresh connect, and seal falsy
scalar plugin configs with nullish presence checks end-to-end.
Related to #2269
* fix(plugins): apply sidecars under current declared setting scopes
When a plugin update changes a syncable setting scope, write under the
live declaration and drop the obsolete scope row so collection does not
republish duplicates.
Related to #2269
* fix(plugins): seal config envelopes and stabilize provider counters
Use an unambiguous host-owned sealed-config envelope, initialize
dynamic-provider sequence counters before cross-window decrypt, keep
three-way sidecar deletions during convergent migration, and create the
sidecar table for existing schema-1 plugin databases.
Related to #2269
* fix(plugins): avoid mid-sync adapter drops and sidecar resurrection
Keep plugin adapters across setting-only contribution refreshes and
rebind sessions on ensureConnected; stop preserving local-only missing
plugin sidecars when remote already mentions the plugin; compare configs
with nullish semantics across windows; swallow quick-connect failures
after toasting.
Related to #2269
* fix(plugins): restore remoteKeys for installed sidecar resets
Reintroduce the remote key set used when dropping installed-plugin
settings the remote no longer carries so applyFromSync cannot throw
after replacing the sidecar table.
Related to #2269
* fix(plugins): honor conflict policy and re-key scoped sidecars
Pass preferCloud/preferLocal into three-way sidecar merge, use
per-provider baselines on convergent downgrade so local resets are not
resurrected, and re-key applied settings under the current declared
scope coordinates.
Related to #2269
* fix(plugins): keep strategy on final sidecar merge and block dual legacy
Pass preferCloud/preferLocal through the final verified sidecar merge,
avoid auto-reactivating a retained plugin while another legacy provider
is already connected, cache plugin adapters only after initializeSync,
and preserve explicit null configuration values.
Related to #2269
* fix(plugins): strip plugin avatars and protect last-known sidecars
Do not load plugin-supplied avatar URLs in the main renderer, and avoid
overwriting last-known sidecars with an unmerged remote bundle when the
post-apply collect fails or is non-authoritative.
Related to #2269
* fix(plugins): strip top-bar plugin avatars and stop always-rebind
Hide plugin avatar URLs in the sync status popover, drop always-on
rebindSession for plugin adapters, and read convergent mode from the
local config module when deciding whether to auto-reactivate providers.
Related to #2269
* fix(plugins): use window.netcatty for sync IPC and allow null configs
Read plugin sync and sidecar APIs from the production preload global,
and treat an explicit config property (including JSON null) as present
for readiness and adapter lookup.
Related to #2269
* fix(plugins): close residual Codex P2 sync edge cases
Three-way merge sidecars during v2 convergent migration so local
resets are not resurrected, preserve explicit null plugin configs on
adapter rebuild, rebind EOS sessions after I/O failure for replaced
runtimes, and accept the public inlineObjectBytes limit on readObject.
Related to #2269
* fix(plugins): keep inline read cutoff under control-plane budget
Restore the base64-safe inline cutoff so advertised-size reads cannot
overflow the 128 KiB provider JSON envelope; providers near the public
inlineObjectBytes limit must stream.
Related to #2269
* fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals
Close remaining Codex/review gaps for PR8: keep empty sidecar shells from
bypassing upload guards, skip startup hydrate when local settings are newer,
seal/validate durable plugin credential refs, and cover schema 1→2 migration.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): rebind plugin sync sessions and keep null configs
Address Codex P2 on 8ae52c54: re-issue connect after runtime replacement,
and treat stored config:null as present in the dashboard reconnect path.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): defer empty sidecar last-known until sync succeeds
Keep prior non-empty last-known across an authoritative empty collect so the
empty-vault guard can still recognize a plugin-only reset, then commit the
empty cache after a successful upload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): avoid restricted localStorage global in sidecar test
Bind the mock to a local binding so eslint no-restricted-globals stays clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): commit applied sidecar cache and reset orphan settings
Prefer merged payload sidecars when updating last-known after sync, and clear
syncable plugin_settings that lack sidecar rows on authoritative remote deletes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): reseal marker-shaped plugin configs instead of skipping
Exact __netcatty_plugin_config_v1 collisions no longer bypass encryption; trusted
host envelopes are unwrapped then resealed, and false collisions are sealed whole.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): keep merged sidecars in last-known after apply collect fails
Return applyFromSync entries from IPC and use them when follow-up collect fails,
so preserved missing-plugin rows are not dropped from the offline upload cache.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): delete stale-scope settings at stored coordinates on reset
After resetSetting against the current declared scope, also remove the row at
the sidecar's stored scope so scope migrations cannot republish remote deletes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): close sync-provider credential, stream, and sidecar gaps
Make SecretRef usable end-to-end via lease-bound network.request, keep
renderer/main transfers abortable and bounded, and stop sidecar
last-known/pending from corrupting backups and conflict apply.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): harden single-provider, secret schema, and conditional conflicts
Keep legacy restart from dual-connecting providers, extract writeOnly schema
secrets into OS storage, and surface conditional-write precondition failures
as conflict UI instead of a generic sync error.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): dedupe sync type imports for lint
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: address Codex review on PR #2713
* fix(plugins): wait for sidecar host before version backup latch
Defer the one-shot upgrade snapshot until the plugin sidecar host is ready (or a short grace), and catch getDataHash failures so pending/baseline paths do not reject unhandled.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(plugins): require sidecar grace before version backup
Always wait for contributions/grace tick before the upgrade snapshot, and only latch after a non-cancelled attempt so cleanup cannot suppress retries.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
1553 lines
57 KiB
JavaScript
1553 lines
57 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" });
|
|
});
|