[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
"use strict";
const { ipcRenderer } = require("electron");
let hostPort;
let runtimeReady = false;
function connectRuntime() {
if (!hostPort || !runtimeReady) return;
const port = hostPort;
hostPort = undefined;
window.postMessage({
type: "netcatty-plugin:connect",
runtimeToken: window.location.hostname,
}, "*", [port]);
}
window.addEventListener("message", (event) => {
if (event.data?.runtimeToken !== window.location.hostname) return;
if (event.data?.type === "netcatty-plugin:runtime-ready") {
runtimeReady = true;
connectRuntime();
} else if (event.data?.type === "netcatty-plugin:runtime-connected") {
ipcRenderer.send("netcatty-plugin:runtime-connected");
}
});
ipcRenderer.once("netcatty-plugin:connect", (event) => {
hostPort = event.ports?.[0];
connectRuntime();
});
ipcRenderer.send("netcatty-plugin:preload-ready");

View File

@@ -0,0 +1,74 @@
import { startPluginRuntime } from "./runtimePeer.mjs";
function disableDirectBrowserCapability(name) {
try {
Object.defineProperty(globalThis, name, {
configurable: false,
enumerable: false,
value: undefined,
writable: false,
});
} catch {
try { globalThis[name] = undefined; } catch {}
}
}
function lockDirectBrowserCapabilities() {
for (const name of [
"EventSource",
"RTCPeerConnection",
"SharedWorker",
"WebSocket",
"WebSocketStream",
"WebTransport",
"Worker",
"XMLHttpRequest",
"fetch",
"webkitRTCPeerConnection",
]) disableDirectBrowserCapability(name);
try {
Object.defineProperty(Navigator.prototype, "sendBeacon", {
configurable: false,
enumerable: false,
value: () => false,
writable: false,
});
} catch {}
}
function waitForPort() {
return new Promise((resolve) => {
const acceptPort = (event) => {
if (
event.data?.type !== "netcatty-plugin:connect"
|| event.data?.runtimeToken !== window.location.hostname
) return;
const port = event.ports?.[0];
if (!port) return;
window.removeEventListener("message", acceptPort);
resolve(port);
};
window.addEventListener("message", acceptPort);
window.postMessage({
type: "netcatty-plugin:runtime-ready",
runtimeToken: window.location.hostname,
}, "*");
});
}
const [configResponse, port] = await Promise.all([
fetch(new URL("./config.json", import.meta.url), { cache: "no-store", credentials: "omit" }),
waitForPort(),
]);
if (!configResponse.ok) throw new Error("Unable to load plugin runtime configuration");
const config = await configResponse.json();
lockDirectBrowserCapabilities();
await startPluginRuntime({
port,
config,
loadPlugin: (entryUrl) => import(entryUrl),
});
window.postMessage({
type: "netcatty-plugin:runtime-connected",
runtimeToken: window.location.hostname,
}, "*");

View File

@@ -0,0 +1,11 @@
let mappings = new Map();
export function initialize(data) {
mappings = new Map(Object.entries(data?.mappings ?? {}));
}
export function resolve(specifier, context, nextResolve) {
const mapped = mappings.get(specifier);
if (mapped) return { url: mapped, shortCircuit: true };
return nextResolve(specifier, context);
}

View File

@@ -0,0 +1,385 @@
import {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
createMessagePortStreamEnvelope,
materializeStreamChunk,
} from "@netcatty/plugin-contract";
import { PluginError } from "@netcatty/plugin-sdk";
function assertStreamId(streamId) {
if (typeof streamId !== "string"
|| streamId.length < 1
|| streamId.length > PLUGIN_STREAM_MAX_ID_LENGTH
|| streamId.includes("\0")) {
throw new PluginError("invalid_argument", "Plugin stream ID is invalid");
}
return streamId;
}
function assertWindowBytes(windowBytes) {
if (!Number.isSafeInteger(windowBytes)
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
throw new PluginError("invalid_argument", "Plugin stream window is invalid");
}
return windowBytes;
}
function copyBytes(value) {
let source;
if (value instanceof Uint8Array) source = value;
else if (value instanceof ArrayBuffer) source = new Uint8Array(value);
else throw new PluginError("invalid_argument", "Plugin stream writes require Uint8Array or ArrayBuffer");
const copy = new Uint8Array(source.byteLength);
copy.set(source);
return copy;
}
function closedError(streamId) {
return new PluginError("unavailable", `Plugin stream is closed: ${streamId}`);
}
export function createPluginStreamEndpoint(transport, options = {}) {
const maxStreams = options.maxStreams ?? 128;
const incoming = new Map();
const outgoing = new Map();
const pendingIncoming = new Map();
let closed = false;
const sendEnvelope = (frame, transfer) => {
const envelope = createMessagePortStreamEnvelope(frame, transfer);
transport.post(envelope, transfer ? [transfer] : []);
};
const activeStreams = () => incoming.size + outgoing.size;
const reservedStreams = () => activeStreams() + pendingIncoming.size;
function releaseCurrent(state) {
if (!state.currentCreditBytes || closed) return;
const creditBytes = state.currentCreditBytes;
state.currentCreditBytes = 0;
state.availableBytes += creditBytes;
state.updateSequence += 1;
sendEnvelope({
streamId: state.streamId,
sequence: state.updateSequence,
kind: "windowUpdate",
creditBytes,
});
}
function settleIncomingWaiters(state) {
while (state.readers.length > 0) {
if (state.queue.length > 0) {
const reader = state.readers.shift();
const chunk = state.queue.shift();
state.currentCreditBytes = chunk.creditBytes;
reader.resolve(chunk.data);
continue;
}
if (!state.closed) break;
const reader = state.readers.shift();
if (state.error) reader.reject(state.error);
else reader.resolve(null);
}
}
function closeIncoming(state, error, notify = false) {
if (state.closed) return;
state.closed = true;
state.error = error ?? null;
incoming.delete(state.streamId);
if (notify) {
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence,
kind: "cancel",
});
} catch {}
}
settleIncomingWaiters(state);
}
function readableHandle(state) {
return Object.freeze({
id: state.streamId,
async read() {
if (state.readers.length > 0) {
throw new PluginError("failed_precondition", "Plugin stream does not allow concurrent reads");
}
if (state.currentCreditBytes) releaseCurrent(state);
if (state.queue.length > 0) {
const chunk = state.queue.shift();
state.currentCreditBytes = chunk.creditBytes;
return chunk.data;
}
if (state.closed) {
if (state.error) throw state.error;
return null;
}
return new Promise((resolve, reject) => state.readers.push({ resolve, reject }));
},
cancel() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was cancelled"), true); },
dispose() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was disposed"), true); },
});
}
function flushOutgoing(state) {
while (!state.closed && state.queue.length > 0) {
const pending = state.queue[0];
if (pending.bytes.byteLength > state.availableBytes) break;
state.queue.shift();
state.queuedBytes -= pending.bytes.byteLength;
state.availableBytes -= pending.bytes.byteLength;
state.nextSequence += 1;
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence,
kind: "chunk",
data: { encoding: "transfer", byteLength: pending.bytes.byteLength },
}, pending.bytes.buffer);
pending.resolve();
} catch (error) {
state.closed = true;
outgoing.delete(state.streamId);
pending.reject(error);
for (const queued of state.queue.splice(0)) queued.reject(error);
state.queuedBytes = 0;
state.endReject?.(error);
}
}
if (!state.closed && state.terminal === "end" && state.queue.length === 0 && !state.terminalSent) {
state.terminalSent = true;
state.nextSequence += 1;
try {
sendEnvelope({ streamId: state.streamId, sequence: state.nextSequence, kind: "end" });
state.endResolve?.();
if (state.availableBytes === state.windowBytes) {
state.closed = true;
outgoing.delete(state.streamId);
}
} catch (error) {
state.closed = true;
outgoing.delete(state.streamId);
state.endReject?.(error);
}
}
}
function writableHandle(state) {
return Object.freeze({
id: state.streamId,
write(value) {
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
const bytes = copyBytes(value);
if (bytes.byteLength < 1 || bytes.byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
return Promise.reject(new PluginError("out_of_range", "Plugin stream chunk size is invalid"));
}
if (state.queuedBytes + bytes.byteLength > state.windowBytes) {
return Promise.reject(new PluginError("resource_exhausted", "Plugin stream pending writes exceed its window"));
}
return new Promise((resolve, reject) => {
state.queue.push({ bytes, resolve, reject });
state.queuedBytes += bytes.byteLength;
flushOutgoing(state);
});
},
end() {
if (state.terminal === "end") return state.endPromise;
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
state.terminal = "end";
state.endPromise = new Promise((resolve, reject) => {
state.endResolve = resolve;
state.endReject = reject;
});
flushOutgoing(state);
return state.endPromise;
},
fail(error) {
if (state.closed || state.terminal) return;
state.terminal = "error";
state.closed = true;
outgoing.delete(state.streamId);
const failure = new PluginError("data_loss", String(error?.message ?? "Plugin stream failed"));
for (const pending of state.queue.splice(0)) pending.reject(failure);
state.queuedBytes = 0;
state.nextSequence += 1;
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence,
kind: "error",
error: {
code: -32013,
message: String(error?.message ?? "Plugin stream failed").slice(0, 2048),
},
});
},
cancel() {
if (state.closed) return;
state.closed = true;
outgoing.delete(state.streamId);
const error = new PluginError("cancelled", "Plugin stream was cancelled");
for (const pending of state.queue.splice(0)) pending.reject(error);
state.queuedBytes = 0;
state.endReject?.(error);
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence + 1,
kind: "cancel",
});
},
dispose() { this.cancel(); },
});
}
function accept(message) {
if (!message || typeof message !== "object" || !Object.hasOwn(message, "frame")) return false;
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
const frame = envelope.frame;
if (frame.kind === "open") {
if (!pendingIncoming.has(frame.streamId)) return false;
if (closed || activeStreams() >= maxStreams || incoming.has(frame.streamId) || outgoing.has(frame.streamId)) {
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${frame.streamId}`);
}
const state = {
streamId: frame.streamId,
availableBytes: frame.windowBytes,
nextSequence: 1,
updateSequence: -1,
currentCreditBytes: 0,
queue: [],
readers: [],
closed: false,
error: null,
};
incoming.set(frame.streamId, state);
const waiter = pendingIncoming.get(frame.streamId);
pendingIncoming.delete(frame.streamId);
waiter.resolve(readableHandle(state));
return true;
}
const output = outgoing.get(frame.streamId);
if (output && frame.kind === "windowUpdate") {
if (frame.sequence !== output.lastUpdateSequence + 1) {
throw new PluginError("data_loss", `Plugin stream credit is out of order: ${frame.streamId}`);
}
output.lastUpdateSequence = frame.sequence;
output.availableBytes += frame.creditBytes;
if (output.availableBytes > output.windowBytes) {
throw new PluginError("data_loss", `Plugin stream credit exceeds its window: ${frame.streamId}`);
}
if (output.terminalSent && output.availableBytes === output.windowBytes) {
output.closed = true;
outgoing.delete(frame.streamId);
} else flushOutgoing(output);
return true;
}
if (output && frame.kind === "cancel") {
output.closed = true;
outgoing.delete(frame.streamId);
const error = new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`);
for (const pending of output.queue.splice(0)) pending.reject(error);
output.queuedBytes = 0;
output.endReject?.(error);
return true;
}
const input = incoming.get(frame.streamId);
if (!input || input.closed) throw new PluginError("data_loss", `Unknown Plugin stream: ${frame.streamId}`);
if (frame.sequence !== input.nextSequence) {
throw new PluginError("data_loss", `Plugin stream frame is out of order: ${frame.streamId}`);
}
input.nextSequence += 1;
if (frame.kind === "chunk") {
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
if (materialized.encoding !== "binary" || !(materialized.bytes instanceof Uint8Array)) {
throw new PluginError("data_loss", "Plugin byte stream received a non-binary chunk");
}
const data = materialized.bytes;
if (data.byteLength > input.availableBytes) {
throw new PluginError("resource_exhausted", `Plugin stream exceeded receive credit: ${frame.streamId}`);
}
input.availableBytes -= data.byteLength;
input.queue.push({ data, creditBytes: data.byteLength });
settleIncomingWaiters(input);
return true;
}
closeIncoming(
input,
frame.kind === "error"
? new PluginError("data_loss", frame.error.message)
: frame.kind === "cancel"
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
: null,
);
return true;
}
return Object.freeze({
accept,
async acceptReadable(streamId) {
const id = assertStreamId(streamId);
const existing = incoming.get(id);
if (existing) return readableHandle(existing);
if (closed || pendingIncoming.has(id) || outgoing.has(id)) {
throw new PluginError("failed_precondition", `Plugin stream cannot be accepted: ${id}`);
}
if (reservedStreams() >= maxStreams) {
throw new PluginError("resource_exhausted", "Plugin stream limit is exhausted");
}
return new Promise((resolve, reject) => pendingIncoming.set(id, { resolve, reject }));
},
rejectReadable(streamId, error = new PluginError("cancelled", "Plugin stream acceptance was cancelled")) {
const id = assertStreamId(streamId);
const waiter = pendingIncoming.get(id);
if (!waiter) return false;
pendingIncoming.delete(id);
waiter.reject(error);
return true;
},
async openWritable(streamId, windowBytes = 256 * 1024) {
const id = assertStreamId(streamId);
const window = assertWindowBytes(windowBytes);
if (closed || reservedStreams() >= maxStreams || incoming.has(id) || outgoing.has(id) || pendingIncoming.has(id)) {
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${id}`);
}
const state = {
streamId: id,
windowBytes: window,
availableBytes: window,
nextSequence: 0,
lastUpdateSequence: -1,
queue: [],
queuedBytes: 0,
terminal: null,
terminalSent: false,
endPromise: null,
endResolve: null,
endReject: null,
closed: false,
};
outgoing.set(id, state);
sendEnvelope({ streamId: id, sequence: 0, kind: "open", windowBytes: window });
return writableHandle(state);
},
close(error = new PluginError("unavailable", "Plugin stream transport closed")) {
if (closed) return;
closed = true;
for (const state of incoming.values()) closeIncoming(state, error);
for (const state of outgoing.values()) {
state.closed = true;
for (const pending of state.queue.splice(0)) pending.reject(error);
state.queuedBytes = 0;
state.endReject?.(error);
}
outgoing.clear();
for (const waiter of pendingIncoming.values()) waiter.reject(error);
pendingIncoming.clear();
},
});
}
export { assertStreamId, assertWindowBytes };

View File

@@ -0,0 +1,957 @@
import {
CancellationTokenSource,
DisposableStore,
PluginError,
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
} from "@netcatty/plugin-sdk";
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
let terminalInterceptorTransportPromise;
function loadTerminalInterceptorTransport() {
terminalInterceptorTransportPromise ??= import("../terminalInterceptorTransport.cjs")
.then((module) => module.default);
return terminalInterceptorTransportPromise;
}
const RPC_ERRORS = {
methodNotFound: -32601,
invalidParams: -32602,
internal: -32603,
cancelled: -32001,
unsupported: -32012,
};
const PLUGIN_ERROR_NAMES_BY_WIRE_CODE = new Map(
Object.entries(PLUGIN_ERROR_WIRE_CODES).map(([name, code]) => [code, name]),
);
const PROVIDER_KINDS = new Set([
"terminal.completion",
"terminal.decoration",
"terminal.link",
"terminal.hover",
"terminal.matcher",
"terminal.semantic",
"terminal.prompt",
"terminal.background",
"terminal.theme",
"terminal.interceptor.input",
"terminal.interceptor.output",
"connection",
"authentication",
"sync",
"importer",
]);
const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
"validateConfiguration",
"probe",
"open",
"resize",
"signal",
"reconnect",
"close",
"getStatus",
]);
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
"connect",
"disconnect",
"getAccount",
"getCapabilities",
"readObject",
"writeObject",
"deleteObject",
]);
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
function pluginErrorNameFromRpcError(error) {
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
if (error?.code === RPC_ERRORS.methodNotFound) return "unsupported";
if (error?.code === RPC_ERRORS.invalidParams) return "invalid_argument";
if (error?.code === RPC_ERRORS.internal) return "internal";
return PLUGIN_ERROR_NAMES_BY_WIRE_CODE.get(error?.code) ?? "unknown";
}
function freezeRuntimeJson(value) {
const clone = structuredClone(value);
const freeze = (item) => {
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
return Object.freeze(item);
};
return freeze(clone);
}
function messageData(value) {
return value && typeof value === "object" && "data" in value ? value.data : value;
}
function closeTransferredPorts(ports) {
for (const port of ports ?? []) port?.close?.();
}
function createTransportAdapter(port) {
const listeners = new Set();
const handle = (event) => {
const value = messageData(event);
for (const listener of listeners) listener(value, event?.ports ?? []);
};
if (typeof port.addEventListener === "function") port.addEventListener("message", handle);
else port.on("message", handle);
port.start?.();
return {
post(message, transfer = []) {
port.postMessage(message, transfer);
},
onMessage(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
close() {
listeners.clear();
port.close?.();
},
};
}
function makeRpcFailure(id, code, message, data) {
return {
jsonrpc: "2.0",
id,
error: { code, message, ...(data === undefined ? {} : { data }) },
};
}
function cancelUnhandledStream(transport, message) {
const keys = Reflect.ownKeys(message);
if (keys.some((key) => typeof key !== "string" || (key !== "frame" && key !== "transfer"))) {
throw new TypeError("Plugin stream envelope contains unknown properties");
}
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(message, key);
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("Plugin stream envelope must contain enumerable data properties");
}
}
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
if (envelope.frame.kind !== "open") {
throw new Error(`Unknown incoming plugin stream: ${envelope.frame.streamId}`);
}
transport.post(createMessagePortStreamEnvelope({
streamId: envelope.frame.streamId,
sequence: 1,
kind: "cancel",
}));
}
function normalizeError(error) {
if (error instanceof PluginError) {
return pluginErrorToRpcError(error);
}
return { code: RPC_ERRORS.internal, message: "Plugin operation failed" };
}
function createHostClient(transport) {
let nextId = 0;
const pending = new Map();
function accept(message) {
if (!message || typeof message !== "object" || !Object.hasOwn(message, "id")) return false;
if (!Object.hasOwn(message, "result") && !Object.hasOwn(message, "error")) return false;
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
if (!request) return false;
pending.delete(`${typeof message.id}:${String(message.id)}`);
if (message.error) {
request.reject(new PluginError(
pluginErrorNameFromRpcError(message.error),
message.error.message,
message.error.data?.details,
));
} else request.resolve(message.result);
return true;
}
function request(method, params, options = {}) {
const id = nextId;
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
return new Promise((resolve, reject) => {
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
transport.post({
jsonrpc: "2.0",
id,
method,
params,
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
});
});
}
function notify(method, params) {
transport.post({ jsonrpc: "2.0", method, params });
}
function close() {
const error = new PluginError("unavailable", "Plugin host disconnected");
for (const request of pending.values()) request.reject(error);
pending.clear();
}
return { accept, request, notify, close };
}
function assertStorageKey(key) {
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
throw new PluginError("invalid_argument", "Plugin storage key is invalid");
}
return key;
}
function assertCredentialRef(credential) {
if (
!credential
|| typeof credential !== "object"
|| (credential.kind !== "secret" && credential.kind !== "credential")
|| typeof credential.id !== "string"
|| credential.id.length < 16
|| credential.id.length > 256
) throw new PluginError("invalid_argument", "Credential reference is invalid");
if (
credential.kind === "secret"
&& (
typeof credential.key !== "string"
|| credential.key.length < 1
|| credential.key.length > 256
|| credential.key.includes("\0")
)
) throw new PluginError("invalid_argument", "Credential reference is invalid");
return credential.kind === "secret"
? { kind: "secret", id: credential.id, key: credential.key }
: { kind: "credential", id: credential.id };
}
function forwardedDeadline(value, maximum) {
return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : undefined;
}
function assertCompanionId(companionId) {
if (typeof companionId !== "string" || companionId.length < 5 || companionId.length > 192) {
throw new PluginError("invalid_argument", "Companion ID is invalid");
}
return companionId;
}
function assertOwnedContributionId(pluginId, id, label) {
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`) || id.length > 256) {
throw new PluginError("invalid_argument", `${label} ID is invalid`);
}
return id;
}
function assertProviderKind(kind) {
if (!PROVIDER_KINDS.has(kind)) {
throw new PluginError("invalid_argument", "Plugin Provider kind is invalid");
}
return kind;
}
function operationMapLabel(kind) {
if (kind === "connection") return "Connection";
if (kind === "importer") return "Importer";
if (kind === "sync") return "Sync";
return kind;
}
function operationsForProviderKind(kind) {
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
return null;
}
function normalizeProviderHandler(kind, handler) {
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
if (typeof handler !== "function") {
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
}
return handler;
}
const label = operationMapLabel(kind);
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
}
const normalized = {};
const operations = operationsForProviderKind(kind);
for (const operation of operations) {
if (typeof handler[operation] !== "function") {
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
}
normalized[operation] = handler[operation].bind(handler);
}
return Object.freeze(normalized);
}
function assertOwnedContextKey(pluginId, key) {
const prefix = `${pluginId}.`;
const suffix = typeof key === "string" && key.startsWith(prefix)
? key.slice(prefix.length)
: "";
if (!/^[A-Za-z0-9_][A-Za-z0-9_:-]{0,255}$/u.test(suffix)
|| key.length > 256) {
throw new PluginError("invalid_argument", "Plugin Context Key ID is invalid");
}
return key;
}
function normalizeRuntimeEnvironment(value) {
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const sourceTokens = source.themeTokens && typeof source.themeTokens === "object"
&& !Array.isArray(source.themeTokens)
? source.themeTokens
: {};
const themeTokens = Object.freeze(Object.fromEntries(
Object.entries(sourceTokens).filter(([key, token]) => key.length > 0 && typeof token === "string"),
));
return Object.freeze({
locale: typeof source.locale === "string" ? source.locale : "en",
theme: typeof source.theme === "string" ? source.theme : "system",
reducedMotion: source.reducedMotion === true,
highContrast: source.highContrast === true,
themeTokens,
});
}
function createEmitter() {
const listeners = new Set();
return {
event(listener) {
if (typeof listener !== "function") throw new PluginError("invalid_argument", "Plugin event listener must be a function");
listeners.add(listener);
return Object.freeze({ dispose: () => listeners.delete(listener) });
},
fire(value) {
for (const listener of [...listeners]) {
try { listener(value); } catch {}
}
},
clear() { listeners.clear(); },
};
}
function createPluginContext(config, client, runtimeApi) {
const subscriptions = new DisposableStore();
const storage = {
get: (key) => client.request("storage.get", { key: assertStorageKey(key) })
.then((result) => result?.found ? result.value : undefined),
set: (key, value) => client.request("storage.set", { key: assertStorageKey(key), value })
.then(() => undefined),
delete: (key) => client.request("storage.delete", { key: assertStorageKey(key) })
.then(() => undefined),
keys: () => client.request("storage.keys", {}).then((result) => result?.keys ?? []),
};
const secrets = {
get: (key) => client.request("secrets.get", { key: assertStorageKey(key) })
.then((result) => result?.found ? result.secret : undefined),
set: (key, value) => client.request("secrets.set", {
key: assertStorageKey(key),
value,
}).then((result) => result.secret),
delete: (key) => client.request("secrets.delete", { key: assertStorageKey(key) })
.then(() => undefined),
};
const credentials = {
createLease: (secret, options) => client.request("credentials.createLease", {
secret: assertCredentialRef(secret),
operationId: options?.operationId,
purpose: options?.purpose,
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
}),
};
const network = {
request: (request) => client.request("network.request", request, {
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
}),
};
const filesystem = {
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
path: filePath,
...options,
}).then((result) => result.data),
writeFile: (filePath, data, options = {}) => client.request("filesystem.writeFile", {
path: filePath,
data,
...options,
}).then(() => undefined),
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
.then((result) => result.entries),
};
const companions = {
start: async (companionId) => {
const result = await client.request("companion.start", {
companionId: assertCompanionId(companionId),
});
let stopped = false;
let stopPromise = null;
const stop = () => {
if (stopped) return Promise.resolve();
if (stopPromise) return stopPromise;
stopPromise = client.request("companion.stop", { handleId: result.handleId })
.then(() => { stopped = true; })
.finally(() => { stopPromise = null; });
return stopPromise;
};
return Object.freeze({
id: result.handleId,
request: (method, params, options = {}) => client.request(
"companion.request",
{
handleId: result.handleId,
method,
...(params === undefined ? {} : { params }),
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(options.credentialLeases === undefined ? {} : {
credentialLeases: options.credentialLeases,
operationId: options.operationId,
}),
},
{ deadlineMs: forwardedDeadline(options.timeoutMs, 60_000) },
),
stop,
dispose() { void stop().catch(() => {}); },
});
},
};
const streams = {
acceptReadable: (streamId) => runtimeApi.streams.acceptReadable(streamId),
openWritable: (streamId, options = {}) => runtimeApi.streams.openWritable(
streamId,
options.windowBytes,
),
};
const settings = {
get: (settingId, options = {}) => client.request("settings.get", {
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
}).then((result) => result?.found ? result.value : undefined),
update: (settingId, value, options = {}) => client.request("settings.update", {
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
value,
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
}).then((result) => result ?? { restartRequired: false }),
onDidChange: runtimeApi.settingsChanged.event,
};
const commands = {
registerCommand(commandId, handler) {
const id = assertOwnedContributionId(config.pluginId, commandId, "Plugin command");
if (typeof handler !== "function") throw new PluginError("invalid_argument", "Plugin command handler must be a function");
if (runtimeApi.commandHandlers.has(id)) throw new PluginError("already_exists", `Plugin command is already registered: ${id}`);
runtimeApi.commandHandlers.set(id, handler);
return Object.freeze({
dispose() {
if (runtimeApi.commandHandlers.get(id) === handler) {
runtimeApi.commandHandlers.delete(id);
}
},
});
},
executeCommand: (commandId, args) => client.request("commands.execute", {
command: assertOwnedContributionId(config.pluginId, commandId, "Plugin command"),
...(args === undefined ? {} : { args }),
}),
};
const contextKeys = {
set: (key, value) => client.request("contextKeys.set", {
key: assertOwnedContextKey(config.pluginId, key),
value,
}).then(() => undefined),
};
const views = {
onDidReceiveMessage(viewId, listener) {
const id = assertOwnedContributionId(config.pluginId, viewId, "Plugin view");
let emitter = runtimeApi.viewMessages.get(id);
if (!emitter) {
emitter = createEmitter();
runtimeApi.viewMessages.set(id, emitter);
}
return emitter.event(listener);
},
postMessage: (viewId, message) => client.notify("views.postMessage", {
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
message,
}),
getState: (viewId, scopeId) => client.request("views.getState", {
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
scopeId,
}).then((result) => result?.state),
setState: (viewId, scopeId, state) => client.request("views.setState", {
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
scopeId,
state,
}).then(() => undefined),
};
const providers = {
register(providerId, kind, handler) {
const id = assertOwnedContributionId(config.pluginId, providerId, "Plugin Provider");
const normalizedKind = assertProviderKind(kind);
const normalizedHandler = normalizeProviderHandler(normalizedKind, handler);
if (runtimeApi.providerHandlers.has(id)) {
throw new PluginError("already_exists", `Plugin Provider is already registered: ${id}`);
}
const registration = Object.freeze({ kind: normalizedKind, handler: normalizedHandler });
runtimeApi.providerHandlers.set(id, registration);
return Object.freeze({
dispose() {
if (runtimeApi.providerHandlers.get(id) === registration) {
runtimeApi.providerHandlers.delete(id);
}
},
});
},
};
const terminals = {
onDidChange: runtimeApi.terminalEvents.event,
};
const environment = {
get locale() { return runtimeApi.environment.locale ?? "en"; },
get theme() { return runtimeApi.environment.theme ?? "system"; },
get reducedMotion() { return runtimeApi.environment.reducedMotion === true; },
get highContrast() { return runtimeApi.environment.highContrast === true; },
get themeTokens() { return runtimeApi.environment.themeTokens; },
onDidChange: runtimeApi.environmentChanged.event,
};
const logger = Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [
level,
(message, fields) => client.notify("log.write", {
level,
message: String(message).slice(0, 2_048),
...(fields === undefined ? {} : { fields }),
}),
]));
return {
pluginId: config.pluginId,
netcattyVersion: config.netcattyVersion,
apiVersion: config.apiVersion,
enabledFeatures: new Set(config.enabledFeatures),
subscriptions,
storage,
settings,
commands,
contextKeys,
views,
providers,
terminals,
environment,
secrets,
credentials,
network,
filesystem,
companions,
streams,
logger,
};
}
export async function startPluginRuntime({
port,
config,
loadPlugin,
loadTerminalInterceptorTransport: loadTerminalInterceptorTransportForRuntime = loadTerminalInterceptorTransport,
}) {
const transport = createTransportAdapter(port);
const client = createHostClient(transport);
const cancellation = new Map();
let plugin;
let context;
let activated = false;
let deactivated = false;
const runtimeApi = {
commandHandlers: new Map(),
providerHandlers: new Map(),
settingsChanged: createEmitter(),
environmentChanged: createEmitter(),
terminalEvents: createEmitter(),
environment: normalizeRuntimeEnvironment(config.environment),
viewMessages: new Map(),
terminalInterceptorPorts: new Set(),
streams: null,
};
runtimeApi.streams = createPluginStreamEndpoint(transport);
const pluginModule = await loadPlugin(config.entryUrl);
plugin = pluginModule?.default;
if (!plugin || typeof plugin.activate !== "function") {
throw new Error("Plugin entrypoint must default-export a plugin with activate(context)");
}
async function handleRequest(message, cancellationToken, ports = []) {
const isTerminalAttachment = message.method === "plugin.terminal.interceptor.attach";
if (!isTerminalAttachment) closeTransferredPorts(ports);
if (message.method === "plugin.initialize") {
if (context) throw new PluginError("failed_precondition", "Plugin is already initialized");
context = createPluginContext(config, client, runtimeApi);
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: [...config.enabledFeatures],
};
}
if (message.method === "plugin.activate") {
if (!context) throw new PluginError("failed_precondition", "Plugin must be initialized first");
if (!activated) {
if (message.params?.environment && typeof message.params.environment === "object"
&& !Array.isArray(message.params.environment)) {
runtimeApi.environment = normalizeRuntimeEnvironment(message.params.environment);
}
const disposable = await plugin.activate(context);
if (disposable && typeof disposable.dispose === "function") context.subscriptions.add(disposable);
activated = true;
}
return null;
}
if (message.method === "plugin.deactivate") {
if (!deactivated) {
deactivated = true;
await plugin.deactivate?.();
context?.subscriptions.dispose();
}
return null;
}
if (message.method === "plugin.terminal.interceptor.attach") {
if (ports.length !== 1) {
closeTransferredPorts(ports);
throw new PluginError("invalid_argument", "Terminal interceptor attachment requires exactly one port");
}
try {
await attachTerminalInterceptor(message.params, ports, cancellationToken);
return { accepted: true };
} catch (error) {
closeTransferredPorts(ports);
throw error;
}
}
if (message.method === "plugin.command.execute") {
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
const command = assertOwnedContributionId(config.pluginId, message.params?.command, "Plugin command");
const handler = runtimeApi.commandHandlers.get(command);
if (!handler) throw new PluginError("failed_precondition", `Plugin command has no registered handler: ${command}`);
return await handler(message.params?.args, message.params?.invocation);
}
if (message.method === "provider.invoke") {
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
const providerId = assertOwnedContributionId(config.pluginId, message.params?.providerId, "Plugin Provider");
const kind = assertProviderKind(message.params?.kind);
const registration = runtimeApi.providerHandlers.get(providerId);
if (!registration) {
throw new PluginError("failed_precondition", `Plugin Provider has no registered handler: ${providerId}`);
}
if (registration.kind !== kind) {
throw new PluginError("failed_precondition", `Plugin Provider kind changed: ${providerId}`);
}
const requestId = message.params?.requestId;
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128) {
throw new PluginError("invalid_argument", "Plugin Provider request ID is invalid");
}
const operation = message.params?.operation;
if (typeof operation !== "string" || operation.length < 1 || operation.length > 128) {
throw new PluginError("invalid_argument", "Plugin Provider operation is invalid");
}
const deadlineMs = message.params?.deadlineMs;
if (deadlineMs != null && (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000)) {
throw new PluginError("invalid_argument", "Plugin Provider deadline is invalid");
}
const payload = message.params?.payload === undefined
? undefined
: freezeRuntimeJson(message.params.payload);
const connectionOpen = kind === "connection" && operation === "open";
const importerParse = kind === "importer" && operation === "parse";
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
let input;
let output;
let cancelStreams;
if (streamed) {
const inputStreamId = payload?.inputStreamId;
const outputStreamId = payload?.outputStreamId;
const windowBytes = payload?.windowBytes;
if (connectionOpen || importerParse) {
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
}
} else if (syncReadStream && typeof outputStreamId !== "string") {
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
} else if (syncWriteStream && typeof inputStreamId !== "string") {
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
}
if (typeof inputStreamId === "string") {
input = runtimeApi.streams.acceptReadable(inputStreamId);
// Prevent a cancelled request from creating an unhandled rejected
// promise when the Provider never awaited its input stream.
void input.catch(() => {});
}
if (typeof outputStreamId === "string") {
try {
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
} catch (error) {
if (typeof inputStreamId === "string") {
runtimeApi.streams.rejectReadable(inputStreamId, error);
}
throw error;
}
}
cancelStreams = () => {
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
if (typeof inputStreamId === "string") {
runtimeApi.streams.rejectReadable(inputStreamId, error);
void input?.then((stream) => stream.cancel(), () => {});
}
output?.cancel?.();
};
}
const cancellationDisposable = cancelStreams
? cancellationToken.onCancellationRequested(cancelStreams)
: null;
const invocation = Object.freeze({
providerId,
kind,
operation,
requestId,
payload,
deadlineMs,
cancellationToken,
...(input !== undefined ? { input } : {}),
...(output !== undefined ? { output } : {}),
});
try {
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
? registration.handler[operation]
: registration.handler;
if (typeof handler !== "function") {
throw new PluginError("invalid_argument", `${kind} Provider operation is not implemented: ${operation}`);
}
const result = await handler(invocation);
if (cancellationToken.isCancellationRequested) {
return { requestId, status: "cancelled" };
}
// Sync readObject may return inline base64 or found:false while the host
// still opened an output stream for large-object fallback — release it.
if (syncReadStream && output && !(result && result.streamed === true)) {
try { output.cancel?.(); } catch { /* best-effort */ }
}
return { requestId, status: "ok", result: result === undefined ? null : result };
} catch (error) {
cancelStreams?.();
if (cancellationToken.isCancellationRequested) {
return { requestId, status: "cancelled" };
}
const rpcError = normalizeError(error);
return {
requestId,
status: "failed",
error: { code: rpcError.code, message: rpcError.message, ...(rpcError.data === undefined ? {} : { data: rpcError.data }) },
};
} finally {
cancellationDisposable?.dispose();
if (streamed && cancellationToken.isCancellationRequested) cancelStreams();
}
}
throw new PluginError("unsupported", `Unsupported host method: ${message.method}`);
}
function handleNotification(message) {
if (message.method === "plugin.settings.changed") {
runtimeApi.settingsChanged.fire(message.params);
return true;
}
if (message.method === "plugin.environment.changed") {
runtimeApi.environment = normalizeRuntimeEnvironment(message.params);
runtimeApi.environmentChanged.fire(runtimeApi.environment);
return true;
}
if (message.method === "plugin.view.message") {
const viewId = assertOwnedContributionId(config.pluginId, message.params?.viewId, "Plugin view");
runtimeApi.viewMessages.get(viewId)?.fire(message.params?.message);
return true;
}
if (message.method === "plugin.terminal.event") {
runtimeApi.terminalEvents.fire(freezeRuntimeJson(message.params));
return true;
}
return false;
}
const attachTerminalInterceptor = async (message, ports, cancellationToken) => {
const descriptor = message?.descriptor;
const providerId = assertOwnedContributionId(config.pluginId, descriptor?.providerId, "Terminal interceptor");
const direction = descriptor?.direction;
const kind = direction === "input" ? "terminal.interceptor.input"
: direction === "output" ? "terminal.interceptor.output" : null;
const sessionId = descriptor?.session?.sessionId;
if (!kind || typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256) {
throw new PluginError("invalid_argument", "Terminal interceptor descriptor is invalid");
}
const registration = runtimeApi.providerHandlers.get(providerId);
const interceptorPort = ports?.[0];
if (!activated || registration?.kind !== kind || !interceptorPort?.postMessage) {
interceptorPort?.close?.();
throw new PluginError("failed_precondition", "Terminal interceptor provider is not registered");
}
runtimeApi.terminalInterceptorPorts.add(interceptorPort);
let attached = false;
try {
const {
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
createTerminalInterceptorEnvelope,
} = await loadTerminalInterceptorTransportForRuntime();
if (cancellationToken?.isCancellationRequested
|| deactivated
|| !activated
|| !runtimeApi.terminalInterceptorPorts.has(interceptorPort)
|| runtimeApi.providerHandlers.get(providerId) !== registration) {
throw new PluginError("failed_precondition", "Terminal interceptor attachment was cancelled or became stale");
}
const handleChunk = (event) => {
let envelope;
try {
const message = messageData(event);
envelope = createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
} catch {
interceptorPort.close?.();
return;
}
const chunk = envelope.frame;
if (chunk?.type === "netcatty:terminal-interceptor:ready") return;
if (chunk.type !== "netcatty:terminal-interceptor:chunk"
|| chunk.direction !== direction) {
interceptorPort.close?.();
return;
}
const invocation = Object.freeze({
providerId,
kind,
direction,
sequence: chunk.sequence,
session: freezeRuntimeJson(descriptor.session),
data: new Uint8Array(envelope.transfer),
});
// Resolve the registration for every chunk so disposal/re-registration
// cannot keep a captured stale handler alive. Starting from a resolved
// promise also converts synchronous handler throws into the ordinary
// failed response path instead of tearing down the utility runtime.
void Promise.resolve().then(() => {
const currentRegistration = runtimeApi.providerHandlers.get(providerId);
if (currentRegistration?.kind !== kind) {
throw new PluginError("failed_precondition", "Terminal interceptor provider is no longer registered");
}
return currentRegistration.handler(invocation);
}).then(
(result) => {
let bytes;
if (result instanceof Uint8Array) bytes = result;
else if (result instanceof ArrayBuffer) bytes = new Uint8Array(result);
else throw new PluginError("data_loss", "Terminal interceptor must return Uint8Array or ArrayBuffer");
if (bytes.byteLength > TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES) {
throw new PluginError("resource_exhausted", "Terminal interceptor result is too large");
}
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
const resultEnvelope = createTerminalInterceptorEnvelope({
type: "netcatty:terminal-interceptor:result",
sequence: chunk.sequence,
status: "ok",
creditBytes: chunk.byteLength,
byteLength: copy.byteLength,
}, copy.buffer);
interceptorPort.postMessage(resultEnvelope, [copy.buffer]);
},
() => interceptorPort.postMessage(createTerminalInterceptorEnvelope({
type: "netcatty:terminal-interceptor:result",
sequence: chunk.sequence,
status: "failed",
})),
).catch(() => interceptorPort.close?.());
};
if (typeof interceptorPort.addEventListener === "function") {
interceptorPort.addEventListener("message", handleChunk);
} else {
interceptorPort.on("message", handleChunk);
}
interceptorPort.start?.();
interceptorPort.on?.("close", () => runtimeApi.terminalInterceptorPorts.delete(interceptorPort));
attached = true;
} finally {
if (!attached) {
runtimeApi.terminalInterceptorPorts.delete(interceptorPort);
interceptorPort.close?.();
}
}
};
const dispose = transport.onMessage((message, ports) => {
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
for (const port of ports) port?.close?.();
try {
if (!runtimeApi.streams.accept(message)) cancelUnhandledStream(transport, message);
}
catch { transport.close(); }
return;
}
if (client.accept(message)) {
for (const port of ports) port?.close?.();
return;
}
if (!message || message.jsonrpc !== "2.0") {
for (const port of ports) port?.close?.();
return;
}
if (message.method === "$/cancelRequest") {
for (const port of ports) port?.close?.();
cancellation.get(message.params?.cancellationId)?.cancel();
return;
}
if (!Object.hasOwn(message, "id")) {
for (const port of ports) port?.close?.();
try { handleNotification(message); } catch { transport.close(); }
return;
}
const cancellationId = message.cancellationId;
const source = new CancellationTokenSource();
if (cancellationId) cancellation.set(cancellationId, source);
void handleRequest(message, source.token, ports).then(
(result) => transport.post({
jsonrpc: "2.0",
id: message.id,
result: result === undefined ? null : result,
}),
(error) => {
const rpcError = normalizeError(error);
transport.post(makeRpcFailure(message.id, rpcError.code, rpcError.message, rpcError.data));
},
).finally(() => {
if (cancellationId) cancellation.delete(cancellationId);
source.dispose();
});
});
return {
async dispose() {
dispose();
client.close();
for (const source of cancellation.values()) source.cancel();
runtimeApi.commandHandlers.clear();
runtimeApi.providerHandlers.clear();
runtimeApi.settingsChanged.clear();
runtimeApi.environmentChanged.clear();
runtimeApi.terminalEvents.clear();
for (const interceptorPort of runtimeApi.terminalInterceptorPorts) interceptorPort.close?.();
runtimeApi.terminalInterceptorPorts.clear();
runtimeApi.streams.close();
for (const emitter of runtimeApi.viewMessages.values()) emitter.clear();
cancellation.clear();
if (!deactivated) {
await plugin.deactivate?.();
context?.subscriptions.dispose();
}
transport.close();
},
};
}

View File

@@ -0,0 +1,476 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
createTerminalInterceptorEnvelope,
} = require("../terminalInterceptorTransport.cjs");
class FakePort {
constructor() {
this.listeners = new Set();
this.messages = [];
this.closed = false;
}
addEventListener(type, listener) {
if (type === "message") this.listeners.add(listener);
}
removeEventListener(type, listener) {
if (type === "message") this.listeners.delete(listener);
}
postMessage(message, transfer = []) {
this.messages.push({ message, transfer });
}
start() {}
close() { this.closed = true; }
emit(data, ports = []) {
for (const listener of this.listeners) listener({ data, ports });
}
}
async function tick() {
await new Promise((resolve) => setImmediate(resolve));
}
test("utility runtime dispatches dedicated terminal ports to the exact registered interceptor", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({
default: {
activate(context) {
context.providers.register(
"com.example.input",
"terminal.interceptor.input",
({ data }) => Uint8Array.from([...data].map((byte) => byte >= 97 && byte <= 122 ? byte - 32 : byte)),
);
},
},
}),
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "com.example.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
},
}, [dataPort]);
await tick();
assert.equal(
control.messages.some(({ message }) => (
message.jsonrpc === "2.0"
&& message.id === 3
&& message.result?.accepted === true
)),
true,
);
dataPort.emit(createTerminalInterceptorEnvelope({
type: "netcatty:terminal-interceptor:ready",
sessionId: "session-1",
direction: "input",
windowBytes: 64 * 1024,
}));
assert.equal(dataPort.closed, false);
const data = Uint8Array.from(Buffer.from("hello")).buffer;
dataPort.emit(createTerminalInterceptorEnvelope({
type: "netcatty:terminal-interceptor:chunk",
sequence: 1,
direction: "input",
creditBytes: 64 * 1024,
byteLength: data.byteLength,
}, data));
await tick();
assert.equal(dataPort.messages.length, 1);
const result = createTerminalInterceptorEnvelope(
dataPort.messages[0].message.frame,
dataPort.messages[0].message.transfer,
);
assert.equal(result.frame.status, "ok");
assert.equal(result.frame.creditBytes, 5);
assert.equal(Buffer.from(result.transfer).toString("utf8"), "HELLO");
assert.deepEqual(dataPort.messages[0].transfer, [result.transfer]);
await runtime.dispose();
});
test("utility runtime closes a terminal port when provider ownership or kind is invalid", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({ default: { activate() {} } }),
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "com.example.missing",
direction: "input",
session: { sessionId: "session-1" },
},
},
}, [dataPort]);
await tick();
assert.equal(dataPort.closed, true);
assert.equal(
control.messages.some(({ message }) => (
message.jsonrpc === "2.0"
&& message.id === 3
&& message.error?.code === -32009
)),
true,
);
await runtime.dispose();
});
test("utility runtime closes the transferred terminal port when the provider belongs to another plugin", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({ default: { activate() {} } }),
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "other.plugin.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
},
}, [dataPort]);
await tick();
assert.equal(dataPort.closed, true);
assert.equal(
control.messages.some(({ message }) => (
message.jsonrpc === "2.0"
&& message.id === 3
&& message.error?.code === -32003
)),
true,
);
await runtime.dispose();
});
test("utility runtime closes unexpected transferred ports on every lifecycle request", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({ default: { activate() {} } }),
});
const lifecycleRequests = [
{ id: 1, method: "plugin.initialize", params: {} },
{ id: 2, method: "plugin.activate", params: {} },
{ id: 3, method: "plugin.deactivate", params: {} },
];
for (const request of lifecycleRequests) {
const unexpectedPort = new FakePort();
control.emit({ jsonrpc: "2.0", ...request }, [unexpectedPort]);
await tick();
assert.equal(unexpectedPort.closed, true, `${request.method} retained an unexpected port`);
assert.equal(
control.messages.some(({ message }) => (
message.jsonrpc === "2.0"
&& message.id === request.id
&& Object.hasOwn(message, "result")
)),
true,
`${request.method} did not complete after closing its unexpected port`,
);
}
await runtime.dispose();
});
test("utility runtime rejects the retired private terminal attachment protocol", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({ default: { activate() {} } }),
});
const dataPort = new FakePort();
control.emit({
type: "netcatty-plugin:terminal-interceptor:attach",
attachmentId: 1,
descriptor: { providerId: "com.example.input", direction: "input" },
}, [dataPort]);
assert.equal(dataPort.closed, true);
assert.equal(control.messages.length, 0);
await runtime.dispose();
});
test("terminal ports convert synchronous throws to failures and stop using disposed handlers", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
let registration;
let calls = 0;
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({
default: {
activate(context) {
registration = context.providers.register(
"com.example.input",
"terminal.interceptor.input",
() => {
calls += 1;
throw new Error("synchronous failure");
},
);
},
},
}),
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "com.example.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
},
}, [dataPort]);
await tick();
const send = (sequence) => {
const data = Uint8Array.from([sequence]).buffer;
dataPort.emit(createTerminalInterceptorEnvelope({
type: "netcatty:terminal-interceptor:chunk",
sequence,
direction: "input",
creditBytes: 64 * 1024,
byteLength: data.byteLength,
}, data));
};
send(1);
await tick();
assert.equal(dataPort.messages[0].message.frame.status, "failed");
assert.equal(calls, 1);
registration.dispose();
send(2);
await tick();
assert.equal(dataPort.messages[1].message.frame.status, "failed");
assert.equal(calls, 1);
await runtime.dispose();
});
test("the utility peer closes a port whose chunk bypasses the canonical terminal frame schema", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({
default: {
activate(context) {
context.providers.register(
"com.example.input",
"terminal.interceptor.input",
({ data }) => data,
);
},
},
}),
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "com.example.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
},
}, [dataPort]);
await tick();
const data = Uint8Array.from(Buffer.from("unsafe")).buffer;
dataPort.emit({
frame: {
type: "netcatty:terminal-interceptor:chunk",
sequence: 1,
direction: "input",
creditBytes: 64 * 1024,
byteLength: data.byteLength,
extra: true,
},
transfer: data,
});
assert.equal(dataPort.closed, true);
assert.equal(dataPort.messages.length, 0);
await runtime.dispose();
});
test("utility runtime disposal closes a terminal port while its transport helper is loading", async () => {
const { startPluginRuntime } = await import("./runtimePeer.mjs");
const control = new FakePort();
let resolveTransport;
const transportLoading = new Promise((resolve) => { resolveTransport = resolve; });
const runtime = await startPluginRuntime({
port: control,
config: {
pluginId: "com.example",
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "1.0.0",
enabledFeatures: [],
environment: {},
entryUrl: "file:///plugin.js",
},
loadPlugin: async () => ({
default: {
activate(context) {
context.providers.register(
"com.example.input",
"terminal.interceptor.input",
({ data }) => data,
);
},
},
}),
loadTerminalInterceptorTransport: () => transportLoading,
});
control.emit({ jsonrpc: "2.0", id: 1, method: "plugin.initialize", params: {} });
await tick();
control.emit({ jsonrpc: "2.0", id: 2, method: "plugin.activate", params: {} });
await tick();
const dataPort = new FakePort();
control.emit({
jsonrpc: "2.0",
id: 3,
method: "plugin.terminal.interceptor.attach",
params: {
descriptor: {
providerId: "com.example.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
},
}, [dataPort]);
await tick();
assert.equal(dataPort.closed, false);
await runtime.dispose();
assert.equal(dataPort.closed, true);
resolveTransport({
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES: 64 * 1024,
createTerminalInterceptorEnvelope,
});
await tick();
assert.equal(
control.messages.some(({ message }) => message.id === 3 && message.result?.accepted === true),
false,
);
assert.equal(dataPort.closed, true);
});

View File

@@ -0,0 +1,24 @@
import { register } from "node:module";
import process from "node:process";
function messageData(value) {
return value && typeof value === "object" && "data" in value ? value.data : value;
}
const bootstrap = await new Promise((resolve) => {
process.parentPort.once("message", (event) => resolve(messageData(event)));
});
if (bootstrap?.type !== "netcatty-plugin:bootstrap") {
throw new Error("Missing plugin utility runtime bootstrap");
}
register(new URL("./pluginModuleLoader.mjs", import.meta.url), {
parentURL: import.meta.url,
data: { mappings: bootstrap.config.moduleMappings },
});
const { startPluginRuntime } = await import("./runtimePeer.mjs");
await startPluginRuntime({
port: process.parentPort,
config: bootstrap.config,
loadPlugin: (entryUrl) => import(entryUrl),
});
process.parentPort.postMessage({ type: "netcatty-plugin:ready" });

View File

@@ -0,0 +1,65 @@
"use strict";
const { contextBridge, ipcRenderer } = require("electron");
function subscribe(channel, listener) {
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
const handler = (_event, value) => listener(value);
ipcRenderer.on(channel, handler);
return Object.freeze({ dispose: () => ipcRenderer.removeListener(channel, handler) });
}
const ENVIRONMENT_CHANNEL = "netcatty-plugin-view:environment";
const environmentListeners = new Set();
let latestEnvironment;
let environmentGeneration = 0;
let initialEnvironmentRequest;
function publishEnvironment(value) {
latestEnvironment = value;
environmentGeneration += 1;
for (const listener of environmentListeners) listener(value);
}
ipcRenderer.on(ENVIRONMENT_CHANNEL, (_event, value) => publishEnvironment(value));
function subscribeEnvironment(listener) {
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
environmentListeners.add(listener);
if (latestEnvironment !== undefined) {
const value = latestEnvironment;
const generation = environmentGeneration;
queueMicrotask(() => {
if (environmentListeners.has(listener) && generation === environmentGeneration) listener(value);
});
} else if (!initialEnvironmentRequest) {
initialEnvironmentRequest = ipcRenderer.invoke("netcatty-plugin-view:get-environment")
.then((value) => {
if (latestEnvironment === undefined) publishEnvironment(value);
})
.catch(() => {})
.finally(() => { initialEnvironmentRequest = undefined; });
}
return Object.freeze({ dispose: () => environmentListeners.delete(listener) });
}
contextBridge.exposeInMainWorld("netcattyView", Object.freeze({
postMessage(message) {
return ipcRenderer.invoke("netcatty-plugin-view:post-message", message);
},
executeCommand(command, args) {
return ipcRenderer.invoke("netcatty-plugin-view:execute-command", { command, args });
},
getState() {
return ipcRenderer.invoke("netcatty-plugin-view:get-state");
},
setState(state) {
return ipcRenderer.invoke("netcatty-plugin-view:set-state", state);
},
onDidReceiveMessage(listener) {
return subscribe("netcatty-plugin-view:message", listener);
},
onDidChangeEnvironment(listener) {
return subscribeEnvironment(listener);
},
}));