[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
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:
747
electron/plugins/terminalDataPipelineService.cjs
Normal file
747
electron/plugins/terminalDataPipelineService.cjs
Normal file
@@ -0,0 +1,747 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { normalizeTerminalSessionSnapshot } = require("./terminalProviderService.cjs");
|
||||
|
||||
const DIRECTIONS = Object.freeze(["input", "output"]);
|
||||
|
||||
function runtimeIdentityMatches(left, right) {
|
||||
return left?.pluginId === right?.pluginId
|
||||
&& left?.pluginVersion === right?.pluginVersion
|
||||
&& left?.runtimeId === right?.runtimeId
|
||||
&& left?.runtimeKind === right?.runtimeKind
|
||||
&& left?.securityPrincipal === right?.securityPrincipal;
|
||||
}
|
||||
|
||||
function normalizeActivationIdentity(activation) {
|
||||
const identity = activation?.identity;
|
||||
if (!identity
|
||||
|| identity.pluginId !== activation?.plugin?.id
|
||||
|| identity.pluginVersion !== activation?.plugin?.activeVersion
|
||||
|| typeof identity.runtimeId !== "string" || identity.runtimeId.length < 1
|
||||
|| identity.runtimeKind !== "utility"
|
||||
|| typeof identity.securityPrincipal !== "string" || identity.securityPrincipal.length < 1) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor activation identity is unavailable, stale, or not an advanced utility runtime",
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
});
|
||||
}
|
||||
|
||||
function terminalSessionSnapshotsEqual(left, right) {
|
||||
return left?.sessionId === right?.sessionId
|
||||
&& left?.hostId === right?.hostId
|
||||
&& left?.workspaceId === right?.workspaceId
|
||||
&& left?.protocol === right?.protocol
|
||||
&& left?.status === right?.status
|
||||
&& left?.cwd === right?.cwd
|
||||
&& left?.title === right?.title
|
||||
&& left?.shellType === right?.shellType
|
||||
&& left?.cols === right?.cols
|
||||
&& left?.rows === right?.rows
|
||||
&& left?.alternateScreen === right?.alternateScreen;
|
||||
}
|
||||
|
||||
class PluginTerminalDataPipelineService {
|
||||
constructor(options) {
|
||||
if (!options?.contributionService || !options?.permissionEngine
|
||||
|| !options?.runtimeSupervisor || !options?.MessageChannelMain) {
|
||||
throw new TypeError("Terminal data pipeline requires contribution, permission, runtime, and MessagePort services");
|
||||
}
|
||||
this.contributionService = options.contributionService;
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.MessageChannelMain = options.MessageChannelMain;
|
||||
this.requestSelection = options.requestSelection ?? (async () => null);
|
||||
this.showWarning = options.showWarning ?? (() => {});
|
||||
this.terminalWorkerManager = null;
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.active = new Map();
|
||||
this.declined = new Set();
|
||||
this.quarantined = new Set();
|
||||
this.selectedProviders = new Map();
|
||||
this.operations = new Map();
|
||||
this.pendingActivations = new Map();
|
||||
this.failedActivations = new Map();
|
||||
this.finalizedActivations = new WeakSet();
|
||||
this.permissionGenerations = new Map();
|
||||
this.sessionEpochs = new Map();
|
||||
this.nextSessionEpoch = 0;
|
||||
this.pendingOwnership = new Map();
|
||||
this.closed = false;
|
||||
this.permissionRevocationSubscription = this.permissionEngine.onDidRevoke?.((event) => {
|
||||
const pluginId = event?.pluginId;
|
||||
if (typeof pluginId !== "string") return;
|
||||
this.permissionGenerations.set(pluginId, (this.permissionGenerations.get(pluginId) ?? 0) + 1);
|
||||
const affectedDirections = event.permission === "terminal.intercept.input"
|
||||
? new Set(["input"])
|
||||
: event.permission === "terminal.intercept.output"
|
||||
? new Set(["output"])
|
||||
: event.permission === undefined || event.permission === "provider.terminal"
|
||||
? new Set(DIRECTIONS)
|
||||
: new Set();
|
||||
if (affectedDirections.size === 0) return;
|
||||
for (const [key, binding] of [...this.active]) {
|
||||
if (binding.identity.pluginId === pluginId && affectedDirections.has(binding.direction)) {
|
||||
this.#detachKey(key, "permission-revoked");
|
||||
}
|
||||
}
|
||||
for (const [key, selection] of [...this.selectedProviders]) {
|
||||
const direction = key.slice(key.lastIndexOf("\0") + 1);
|
||||
if (selection.pluginId === pluginId && affectedDirections.has(direction)) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
}) ?? null;
|
||||
this.runtimeSupervisor.onDidChangeRuntime?.((event) => {
|
||||
if (event.status === "running") return;
|
||||
const failed = event.status === "error" || event.status === "quarantined";
|
||||
const warnedKeys = new Set();
|
||||
for (const [key, selection] of this.selectedProviders) {
|
||||
if (selection.pluginId === event.pluginId) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, binding] of this.active) {
|
||||
if (binding.identity.pluginId === event.pluginId
|
||||
&& (event.runtimeId == null || binding.identity.runtimeId === event.runtimeId)) {
|
||||
if (failed) {
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: binding.sessionId,
|
||||
direction: binding.direction,
|
||||
providerId: binding.providerId,
|
||||
code: `runtime-${event.status}`,
|
||||
message: event.error
|
||||
?? `Terminal interceptor runtime ${event.status}`,
|
||||
}));
|
||||
warnedKeys.add(key);
|
||||
}
|
||||
this.#detachKey(key, failed ? `runtime-${event.status}` : "runtime-stopped");
|
||||
}
|
||||
}
|
||||
for (const [key, pending] of [
|
||||
...this.pendingActivations,
|
||||
...this.failedActivations,
|
||||
]) {
|
||||
if (this.sessionEpochs.get(pending.sessionId) !== pending.sessionEpoch) {
|
||||
if (this.failedActivations.get(key) === pending) this.failedActivations.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (pending.identity.pluginId !== event.pluginId
|
||||
|| (event.runtimeId != null
|
||||
&& pending.identity.runtimeId != null
|
||||
&& pending.identity.runtimeId !== event.runtimeId)) {
|
||||
continue;
|
||||
}
|
||||
this.finalizedActivations.add(pending);
|
||||
this.selectedProviders.delete(key);
|
||||
if (!failed) continue;
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
if (!warnedKeys.has(key)) {
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: pending.sessionId,
|
||||
direction: pending.direction,
|
||||
providerId: pending.providerId,
|
||||
code: `runtime-${event.status}`,
|
||||
message: event.error
|
||||
?? `Terminal interceptor runtime ${event.status}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (const [key, pending] of this.failedActivations) {
|
||||
if (pending.identity.pluginId === event.pluginId
|
||||
&& (event.runtimeId == null
|
||||
|| pending.identity.runtimeId == null
|
||||
|| pending.identity.runtimeId === event.runtimeId)) {
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.contributionService.onDidChange?.(() => this.#pruneContributions());
|
||||
}
|
||||
|
||||
bindTerminalWorkerManager(manager) {
|
||||
this.workerWarningSubscription?.dispose?.();
|
||||
this.sessionOwnedSubscription?.dispose?.();
|
||||
this.sessionClosedSubscription?.dispose?.();
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.terminalWorkerManager = manager ?? null;
|
||||
if (manager?.onTerminalInterceptorWarning) {
|
||||
this.workerWarningSubscription = manager.onTerminalInterceptorWarning((warning) => {
|
||||
if (warning?.code === "worker-exit") {
|
||||
this.active.clear();
|
||||
return;
|
||||
}
|
||||
const key = `${warning?.sessionId}\0${warning?.direction}`;
|
||||
const binding = this.active.get(key);
|
||||
if (!binding) return;
|
||||
if (warning?.code === "closed" && !runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(binding.identity.pluginId),
|
||||
binding.identity,
|
||||
)) {
|
||||
// RuntimeSupervisor removes the identity before an expected stop
|
||||
// closes the utility port, then publishes the authoritative stopped,
|
||||
// error, or quarantined state. Defer classification to that event so
|
||||
// planned restarts are not quarantined while crashes still are.
|
||||
return;
|
||||
}
|
||||
this.active.delete(key);
|
||||
if (!["detached", "replaced", "shutdown"].includes(warning?.code)) {
|
||||
// A circuit-breaker or protocol failure quarantines this direction
|
||||
// for the rest of the session. Keeping the cached provider would let
|
||||
// the next connected/snapshot event silently reattach the same
|
||||
// broken interceptor with its existing grants.
|
||||
this.selectedProviders.delete(key);
|
||||
this.declined.add(key);
|
||||
this.quarantined.add(key);
|
||||
this.showWarning(warning);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (manager?.onSessionOwned) {
|
||||
this.sessionOwnedSubscription = manager.onSessionOwned((event) => (
|
||||
this.#handleSessionOwned(event)
|
||||
));
|
||||
}
|
||||
if (manager?.onSessionClosed) {
|
||||
this.sessionClosedSubscription = manager.onSessionClosed((event) => {
|
||||
if (typeof event?.sessionId === "string") this.#disposeSession(event.sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#disposeSession(sessionId) {
|
||||
this.permissionEngine.revokeSession?.(sessionId);
|
||||
this.pendingOwnership.delete(sessionId);
|
||||
// Absence is itself an invalid generation. A later reuse of the same
|
||||
// session id receives a process-unique token, so late async work from this
|
||||
// lifetime cannot match the replacement without retaining disposed ids.
|
||||
this.sessionEpochs.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
for (const direction of DIRECTIONS) {
|
||||
const key = this.#key(sessionId, direction);
|
||||
this.declined.delete(key);
|
||||
this.quarantined.delete(key);
|
||||
this.selectedProviders.delete(key);
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleSessionOwned(event) {
|
||||
const pending = this.pendingOwnership.get(event?.sessionId);
|
||||
if (!pending || pending.webContentsId !== event?.webContentsId) return;
|
||||
this.pendingOwnership.delete(event.sessionId);
|
||||
if (this.closed
|
||||
|| this.sessionEpochs.get(event.sessionId) !== pending.sessionEpoch
|
||||
|| !this.terminalWorkerManager?.ownsSession?.(event.sessionId, event.webContentsId)) {
|
||||
return;
|
||||
}
|
||||
await this.handleSessionEvent({ type: "snapshot", session: pending.session }, {
|
||||
webContentsId: pending.webContentsId,
|
||||
locale: pending.locale,
|
||||
});
|
||||
}
|
||||
|
||||
#key(sessionId, direction) {
|
||||
return `${sessionId}\0${direction}`;
|
||||
}
|
||||
|
||||
#providers(direction, locale) {
|
||||
const kind = `terminal.interceptor.${direction}`;
|
||||
return this.contributionService.listProviders({ kind, locale });
|
||||
}
|
||||
|
||||
#pruneContributions() {
|
||||
for (const [key, selection] of this.selectedProviders) {
|
||||
const direction = key.slice(key.lastIndexOf("\0") + 1);
|
||||
if (!this.#providers(direction).some((entry) => (
|
||||
entry.provider.id === selection.providerId && entry.pluginId === selection.pluginId
|
||||
))) {
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, binding] of this.active) {
|
||||
const providers = this.#providers(binding.direction);
|
||||
if (!providers.some((entry) => entry.provider.id === binding.providerId
|
||||
&& entry.pluginId === binding.identity.pluginId
|
||||
&& entry.pluginVersion === binding.identity.pluginVersion)) {
|
||||
this.#detachKey(key, "contribution-removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#detachKey(key, reason) {
|
||||
const binding = this.active.get(key);
|
||||
if (!binding) return;
|
||||
this.active.delete(key);
|
||||
this.terminalWorkerManager?.detachTerminalInterceptor?.(
|
||||
binding.sessionId,
|
||||
binding.direction,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
detachSession(sessionId, reason = "session-disposed") {
|
||||
for (const direction of DIRECTIONS) this.#detachKey(this.#key(sessionId, direction), reason);
|
||||
}
|
||||
|
||||
async configureDirection(sessionValue, direction, options = {}) {
|
||||
const session = normalizeTerminalSessionSnapshot(sessionValue);
|
||||
if (!DIRECTIONS.includes(direction)) throw new TypeError("Terminal interceptor direction is invalid");
|
||||
if (this.closed) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal data pipeline is closed");
|
||||
}
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
const epoch = options.sessionEpoch ?? this.#ensureSessionEpoch(session.sessionId);
|
||||
const previous = this.operations.get(key) ?? Promise.resolve();
|
||||
const operation = previous.catch(() => {}).then(() => (
|
||||
this.#configureDirection(session, direction, { ...options, sessionEpoch: epoch })
|
||||
));
|
||||
this.operations.set(key, operation);
|
||||
try { return await operation; }
|
||||
finally {
|
||||
if (this.operations.get(key) === operation) this.operations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#assertSessionCurrent(sessionId, epoch) {
|
||||
if (this.closed || this.sessionEpochs.get(sessionId) !== epoch) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal session changed during interceptor activation");
|
||||
}
|
||||
}
|
||||
|
||||
#replaceSessionEpoch(sessionId) {
|
||||
this.nextSessionEpoch += 1;
|
||||
this.sessionEpochs.set(sessionId, this.nextSessionEpoch);
|
||||
return this.nextSessionEpoch;
|
||||
}
|
||||
|
||||
#ensureSessionEpoch(sessionId) {
|
||||
return this.sessionEpochs.get(sessionId) ?? this.#replaceSessionEpoch(sessionId);
|
||||
}
|
||||
|
||||
#assertAttachmentCurrent(session, direction, providerId, identity, options) {
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager?.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session ownership changed during activation",
|
||||
);
|
||||
}
|
||||
const declared = this.#providers(direction).some((entry) => (
|
||||
entry.provider.id === providerId
|
||||
&& entry.pluginId === identity.pluginId
|
||||
&& entry.pluginVersion === identity.pluginVersion
|
||||
));
|
||||
if (!declared) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor contribution changed during activation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
acceptsSessionEvent(event, webContentsId) {
|
||||
const sessionId = event?.session?.sessionId;
|
||||
if (typeof sessionId !== "string" || !Number.isSafeInteger(webContentsId)) return true;
|
||||
const currentOwner = this.terminalWorkerManager?.getSessionOwnerWebContentsId?.(sessionId);
|
||||
return !Number.isSafeInteger(currentOwner) || currentOwner === webContentsId;
|
||||
}
|
||||
|
||||
async #configureDirection(session, direction, options) {
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (!this.terminalWorkerManager?.attachTerminalInterceptor) {
|
||||
return Object.freeze({ status: "unavailable", direction });
|
||||
}
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
if (options.deferUntilOwned === true) {
|
||||
return Object.freeze({ status: "pending-session", direction });
|
||||
}
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session is not owned by the requesting window",
|
||||
);
|
||||
}
|
||||
const providers = this.#providers(direction, options.locale);
|
||||
if (providers.length === 0) return Object.freeze({ status: "none", direction });
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
if (this.quarantined.has(key)) {
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
if (options.providerId == null && this.declined.has(key)) {
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
const existing = this.active.get(key);
|
||||
if (existing && existing.sessionEpoch === options.sessionEpoch
|
||||
&& terminalSessionSnapshotsEqual(existing.session, session)
|
||||
&& providers.some((entry) => entry.provider.id === existing.providerId)
|
||||
&& runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(existing.identity.pluginId),
|
||||
existing.identity,
|
||||
)) {
|
||||
return Object.freeze({
|
||||
status: "active",
|
||||
direction,
|
||||
providerId: existing.providerId,
|
||||
pluginId: existing.identity.pluginId,
|
||||
});
|
||||
}
|
||||
const cachedSelection = this.selectedProviders.get(key);
|
||||
let providerId = options.providerId ?? cachedSelection?.providerId;
|
||||
if (providerId != null && !providers.some((entry) => entry.provider.id === providerId)) {
|
||||
this.selectedProviders.delete(key);
|
||||
providerId = options.providerId;
|
||||
}
|
||||
if (providerId == null && providers.length === 1) providerId = providers[0].provider.id;
|
||||
if (providerId == null) {
|
||||
providerId = await this.requestSelection(Object.freeze({ session, direction, providers }));
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
if (options.webContentsId != null
|
||||
&& !this.terminalWorkerManager?.ownsSession?.(session.sessionId, options.webContentsId)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor session ownership changed during selection",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (providerId == null) {
|
||||
this.declined.add(key);
|
||||
this.selectedProviders.delete(key);
|
||||
return Object.freeze({ status: "declined", direction });
|
||||
}
|
||||
const selected = providers.find((entry) => entry.provider.id === providerId);
|
||||
if (!selected) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Selected Terminal interceptor is unavailable");
|
||||
|
||||
const permissionGeneration = this.permissionGenerations.get(selected.pluginId) ?? 0;
|
||||
let pendingActivation = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
direction,
|
||||
providerId,
|
||||
identity: Object.freeze({
|
||||
pluginId: selected.pluginId,
|
||||
pluginVersion: selected.pluginVersion,
|
||||
runtimeId: null,
|
||||
}),
|
||||
});
|
||||
this.failedActivations.delete(key);
|
||||
this.pendingActivations.set(key, pendingActivation);
|
||||
try {
|
||||
let activation;
|
||||
try {
|
||||
activation = await this.contributionService.activateProvider(providerId);
|
||||
} catch (error) {
|
||||
if (this.sessionEpochs.get(session.sessionId) === options.sessionEpoch
|
||||
&& !this.quarantined.has(key)
|
||||
&& !this.finalizedActivations.has(pendingActivation)) {
|
||||
this.failedActivations.set(key, pendingActivation);
|
||||
}
|
||||
if (this.finalizedActivations.has(pendingActivation) && !this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal interceptor activation was cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
const identity = normalizeActivationIdentity(activation);
|
||||
pendingActivation = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
direction,
|
||||
providerId,
|
||||
identity,
|
||||
});
|
||||
this.pendingActivations.set(key, pendingActivation);
|
||||
if (this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor was disabled after a runtime failure",
|
||||
);
|
||||
}
|
||||
if (activation.plugin.manifest.main?.node == null
|
||||
|| activation.provider.kind !== `terminal.interceptor.${direction}`) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
"Terminal interceptors require an active advanced utility plugin",
|
||||
);
|
||||
}
|
||||
const context = {
|
||||
pluginId: activation.plugin.id,
|
||||
pluginVersion: activation.plugin.activeVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
manifest: activation.plugin.manifest,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
};
|
||||
try {
|
||||
for (const permission of ["provider.terminal", `terminal.intercept.${direction}`]) {
|
||||
const grant = await this.permissionEngine.authorize(context, {
|
||||
permission,
|
||||
resources: ["*"],
|
||||
sessionId: session.sessionId,
|
||||
reason: `Use ${providerId} to intercept Terminal ${direction} data`,
|
||||
operationId: `terminal.interceptor.${direction}:${providerId}`,
|
||||
allowedScopes: ["session", "application", "always"],
|
||||
validateBeforeGrant: () => {
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during authorization",
|
||||
);
|
||||
}
|
||||
if (!runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(identity.pluginId),
|
||||
identity,
|
||||
)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor runtime changed during authorization",
|
||||
);
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
},
|
||||
});
|
||||
if (!["existing", "session", "application", "always"].includes(grant?.scope)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor streams require a session, application, or persistent permission grant",
|
||||
);
|
||||
}
|
||||
this.#assertSessionCurrent(session.sessionId, options.sessionEpoch);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) {
|
||||
this.declined.add(key);
|
||||
this.selectedProviders.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const currentIdentity = this.runtimeSupervisor.getRuntimeIdentity(activation.plugin.id);
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during activation",
|
||||
);
|
||||
}
|
||||
if (!runtimeIdentityMatches(currentIdentity, identity)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Terminal interceptor runtime changed during authorization");
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
|
||||
if (existing?.sessionEpoch === options.sessionEpoch
|
||||
&& existing.providerId === providerId
|
||||
&& terminalSessionSnapshotsEqual(existing.session, session)
|
||||
&& runtimeIdentityMatches(existing.identity, identity)) {
|
||||
return Object.freeze({ status: "active", direction, providerId, pluginId: identity.pluginId });
|
||||
}
|
||||
const channel = new this.MessageChannelMain();
|
||||
const utilityDescriptor = Object.freeze({ providerId, direction, session });
|
||||
const workerDescriptor = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
providerId,
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
session,
|
||||
});
|
||||
let workerAttached = false;
|
||||
try {
|
||||
await this.runtimeSupervisor.attachTerminalInterceptor(
|
||||
identity.pluginId,
|
||||
utilityDescriptor,
|
||||
channel.port2,
|
||||
{ expectedIdentity: identity },
|
||||
);
|
||||
if (!runtimeIdentityMatches(
|
||||
this.runtimeSupervisor.getRuntimeIdentity(identity.pluginId),
|
||||
identity,
|
||||
)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor runtime changed during port attachment",
|
||||
);
|
||||
}
|
||||
if ((this.permissionGenerations.get(identity.pluginId) ?? 0) !== permissionGeneration) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor permission changed during port attachment",
|
||||
);
|
||||
}
|
||||
if (this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
"Terminal interceptor was disabled after a runtime failure",
|
||||
);
|
||||
}
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
this.terminalWorkerManager.attachTerminalInterceptor(workerDescriptor, channel.port1);
|
||||
workerAttached = true;
|
||||
} catch (error) {
|
||||
if (!this.finalizedActivations.has(pendingActivation)) {
|
||||
this.failedActivations.set(key, pendingActivation);
|
||||
}
|
||||
if (workerAttached) {
|
||||
this.terminalWorkerManager.detachTerminalInterceptor(session.sessionId, direction);
|
||||
}
|
||||
try { channel.port1.close?.(); } catch {}
|
||||
try { channel.port2.close?.(); } catch {}
|
||||
if (this.finalizedActivations.has(pendingActivation) && !this.quarantined.has(key)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal interceptor activation was cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.active.set(key, Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
providerId,
|
||||
identity: Object.freeze({ ...identity }),
|
||||
sessionEpoch: options.sessionEpoch,
|
||||
session,
|
||||
}));
|
||||
this.failedActivations.delete(key);
|
||||
this.declined.delete(key);
|
||||
this.selectedProviders.set(key, Object.freeze({
|
||||
providerId,
|
||||
pluginId: identity.pluginId,
|
||||
}));
|
||||
return Object.freeze({ status: "active", direction, providerId, pluginId: identity.pluginId });
|
||||
} finally {
|
||||
if (this.pendingActivations.get(key) === pendingActivation) {
|
||||
this.pendingActivations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleSessionEvent(event, options = {}) {
|
||||
const session = normalizeTerminalSessionSnapshot(event?.session);
|
||||
if (this.closed) return Object.freeze([]);
|
||||
if (!this.acceptsSessionEvent(event, options.webContentsId)) return Object.freeze([]);
|
||||
if (event?.type === "disposed") {
|
||||
this.#disposeSession(session.sessionId);
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (event?.type === "disconnected") {
|
||||
const sessionEpoch = this.#replaceSessionEpoch(session.sessionId);
|
||||
this.detachSession(session.sessionId, "session-disconnected");
|
||||
if (Number.isSafeInteger(options.webContentsId)) {
|
||||
this.pendingOwnership.set(session.sessionId, Object.freeze({
|
||||
session,
|
||||
sessionEpoch,
|
||||
webContentsId: options.webContentsId,
|
||||
locale: options.locale,
|
||||
}));
|
||||
} else {
|
||||
this.pendingOwnership.delete(session.sessionId);
|
||||
}
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (!["created", "connected", "reconnected", "snapshot", "cwdChanged", "titleChanged",
|
||||
"resized", "alternateScreenChanged"].includes(event?.type)) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
if (event.type === "created") {
|
||||
this.detachSession(session.sessionId, "session-replaced");
|
||||
for (const direction of DIRECTIONS) {
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
this.declined.delete(key);
|
||||
this.quarantined.delete(key);
|
||||
this.selectedProviders.delete(key);
|
||||
this.failedActivations.delete(key);
|
||||
}
|
||||
this.#replaceSessionEpoch(session.sessionId);
|
||||
}
|
||||
const sessionEpoch = this.#ensureSessionEpoch(session.sessionId);
|
||||
if (event.type === "created" && options.webContentsId != null) {
|
||||
this.pendingOwnership.set(session.sessionId, Object.freeze({
|
||||
session,
|
||||
sessionEpoch,
|
||||
webContentsId: options.webContentsId,
|
||||
locale: options.locale,
|
||||
}));
|
||||
}
|
||||
const results = [];
|
||||
for (const direction of DIRECTIONS) {
|
||||
try {
|
||||
results.push(await this.configureDirection(session, direction, {
|
||||
...options,
|
||||
sessionEpoch,
|
||||
deferUntilOwned: event.type === "created",
|
||||
}));
|
||||
}
|
||||
catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) {
|
||||
results.push(Object.freeze({ status: "declined", direction }));
|
||||
continue;
|
||||
}
|
||||
const key = this.#key(session.sessionId, direction);
|
||||
if (error?.code === RPC_ERRORS.cancelled
|
||||
|| this.sessionEpochs.get(session.sessionId) !== sessionEpoch
|
||||
|| this.quarantined.has(key)) {
|
||||
results.push(Object.freeze({ status: "cancelled", direction }));
|
||||
continue;
|
||||
}
|
||||
this.showWarning(Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
direction,
|
||||
code: "activation",
|
||||
message: error?.message ?? String(error),
|
||||
}));
|
||||
results.push(Object.freeze({ status: "failed", direction }));
|
||||
}
|
||||
}
|
||||
if (event.type === "created"
|
||||
&& !results.some((result) => result.status === "pending-session")) {
|
||||
this.pendingOwnership.delete(session.sessionId);
|
||||
}
|
||||
return Object.freeze(results);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const key of [...this.active.keys()]) this.#detachKey(key, "shutdown");
|
||||
this.declined.clear();
|
||||
this.quarantined.clear();
|
||||
this.selectedProviders.clear();
|
||||
this.pendingActivations.clear();
|
||||
this.failedActivations.clear();
|
||||
this.pendingOwnership.clear();
|
||||
this.sessionEpochs.clear();
|
||||
this.workerWarningSubscription?.dispose?.();
|
||||
this.sessionOwnedSubscription?.dispose?.();
|
||||
this.sessionClosedSubscription?.dispose?.();
|
||||
this.workerWarningSubscription = null;
|
||||
this.sessionOwnedSubscription = null;
|
||||
this.sessionClosedSubscription = null;
|
||||
this.permissionRevocationSubscription?.dispose?.();
|
||||
this.permissionRevocationSubscription = null;
|
||||
}
|
||||
|
||||
_getSessionEpochCountForTests() {
|
||||
return this.sessionEpochs.size;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DIRECTIONS,
|
||||
PluginTerminalDataPipelineService,
|
||||
normalizeActivationIdentity,
|
||||
runtimeIdentityMatches,
|
||||
};
|
||||
Reference in New Issue
Block a user