Files
NetMesh/electron/plugins/terminalProviderService.cjs

826 lines
34 KiB
JavaScript
Raw Normal View History

"use strict";
const { randomUUID } = require("node:crypto");
const {
assertProviderRequest,
assertProviderResult,
} = require("./contractValidator.cjs");
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
const MAX_PROVIDER_JSON_BYTES = 128 * 1024;
const MAX_TERMINAL_PROVIDERS_PER_REQUEST = 32;
const DEFAULT_PROVIDER_DEADLINE_MS = 1_500;
const TERMINAL_PROVIDER_KINDS = Object.freeze([
"terminal.completion",
"terminal.decoration",
"terminal.link",
"terminal.hover",
"terminal.matcher",
"terminal.semantic",
"terminal.prompt",
"terminal.background",
"terminal.theme",
]);
const TERMINAL_PROVIDER_KIND_SET = new Set(TERMINAL_PROVIDER_KINDS);
const TERMINAL_PROVIDER_OPERATIONS = new Map([
["terminal.completion", "provideCompletions"],
["terminal.decoration", "provideDecorations"],
["terminal.link", "provideLinks"],
["terminal.hover", "provideHovers"],
["terminal.matcher", "provideMatches"],
["terminal.semantic", "provideSemantics"],
["terminal.prompt", "provideAnnotations"],
["terminal.background", "provideBackgrounds"],
["terminal.theme", "provideTheme"],
]);
const TERMINAL_PROVIDER_PERMISSIONS = new Map([
["terminal.completion", ["provider.terminal", "terminal.complete"]],
["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]],
["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.background", ["provider.terminal", "terminal.decorate"]],
["terminal.theme", ["provider.terminal", "terminal.decorate"]],
]);
const TERMINAL_THEME_COLOR_KEYS = Object.freeze([
"background", "foreground", "cursor", "selection",
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow",
"brightBlue", "brightMagenta", "brightCyan", "brightWhite",
]);
const TERMINAL_THEME_COLOR_KEY_SET = new Set(TERMINAL_THEME_COLOR_KEYS);
const TERMINAL_EVENT_TYPES = new Set([
"snapshot",
"created",
"connected",
"reconnected",
"cwdChanged",
"titleChanged",
"resized",
"alternateScreenChanged",
"commandSubmitted",
"commandCompleted",
"disconnected",
"disposed",
]);
const TERMINAL_STATUSES = new Set(["connecting", "connected", "disconnected"]);
const TERMINAL_SHELL_TYPES = new Set(["posix", "fish", "powershell", "cmd", "unknown"]);
function invalidArgument(message) {
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
}
function freezeJson(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 assertBoundedJson(value, label) {
try {
assertPluginJsonValue(value, { maxBytes: MAX_PROVIDER_JSON_BYTES });
} catch (error) {
throw invalidArgument(`${label} must be a bounded JSON value: ${error?.message ?? error}`);
}
return value;
}
function assertBoundedString(value, label, maximum = 256) {
if (typeof value !== "string" || value.length < 1 || value.length > maximum || value.includes("\0")) {
throw invalidArgument(`${label} is invalid`);
}
return value;
}
function assertTerminalProviderKind(kind) {
if (!TERMINAL_PROVIDER_KIND_SET.has(kind)) {
if (kind === "terminal.interceptor.input" || kind === "terminal.interceptor.output") {
throw new PluginRpcError(
RPC_ERRORS.unsupported,
"Raw terminal interceptor providers require the privileged PR 6 transport",
);
}
throw invalidArgument("Terminal Provider kind is invalid");
}
return kind;
}
function normalizeDeadlineMs(value) {
const deadlineMs = value ?? DEFAULT_PROVIDER_DEADLINE_MS;
if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000) {
throw invalidArgument("Terminal Provider deadline is invalid");
}
return deadlineMs;
}
function throwIfCancelled(signal) {
if (signal?.aborted) {
throw new PluginRpcError(RPC_ERRORS.cancelled, "Terminal Provider request was cancelled");
}
}
function optionalString(value, label, maximum = 512) {
if (value == null) return undefined;
return assertBoundedString(value, label, maximum);
}
function normalizeTerminalProtocol(value) {
if (typeof value !== "string"
|| value.length < 1
|| value.length > 256
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value)) {
throw invalidArgument("Terminal session protocol is invalid");
}
return value;
}
function normalizeTerminalSessionSnapshot(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw invalidArgument("Terminal session snapshot must be an object");
}
const protocol = normalizeTerminalProtocol(value.protocol);
const status = value.status;
if (!TERMINAL_STATUSES.has(status)) throw invalidArgument("Terminal session status is invalid");
if (value.shellType != null && !TERMINAL_SHELL_TYPES.has(value.shellType)) {
throw invalidArgument("Terminal session shell type is invalid");
}
if (value.alternateScreen != null && typeof value.alternateScreen !== "boolean") {
throw invalidArgument("Terminal alternate-screen state is invalid");
}
const normalizeDimension = (dimension, label) => {
if (dimension == null) return undefined;
if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16_384) {
throw invalidArgument(`Terminal session ${label} is invalid`);
}
return dimension;
};
return freezeJson({
sessionId: assertBoundedString(value.sessionId, "Terminal session ID"),
...(value.hostId == null ? {} : { hostId: optionalString(value.hostId, "Terminal host ID") }),
...(value.workspaceId == null ? {} : { workspaceId: optionalString(value.workspaceId, "Terminal workspace ID") }),
protocol,
status,
...(value.cwd == null ? {} : { cwd: optionalString(value.cwd, "Terminal working directory", 4_096) }),
...(value.title == null ? {} : { title: optionalString(value.title, "Terminal title", 1_024) }),
...(value.shellType == null ? {} : { shellType: value.shellType }),
...(value.cols == null ? {} : { cols: normalizeDimension(value.cols, "column count") }),
...(value.rows == null ? {} : { rows: normalizeDimension(value.rows, "row count") }),
...(value.alternateScreen == null ? {} : { alternateScreen: value.alternateScreen === true }),
});
}
function normalizeTerminalSessionEvent(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || !TERMINAL_EVENT_TYPES.has(value.type)) {
throw invalidArgument("Terminal session event is invalid");
}
const event = {
type: value.type,
session: normalizeTerminalSessionSnapshot(value.session),
...(value.exitCode == null ? {} : { exitCode: value.exitCode }),
};
if (event.exitCode != null && !Number.isSafeInteger(event.exitCode)) {
throw invalidArgument("Terminal session exit code is invalid");
}
// PR 5 lifecycle events intentionally exclude command text and raw output.
// Sensitive input/output interception belongs to the privileged PR 6 path.
assertBoundedJson(event, "Terminal session event");
return freezeJson(event);
}
function assertPlainRecord(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be an object`);
}
return value;
}
function assertVisibleString(value, label, maximum) {
assertBoundedString(value, label, maximum);
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint <= 0x1f
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|| (codePoint >= 0x2066 && codePoint <= 0x2069)) {
throw new TypeError(`${label} contains unsafe control characters`);
}
}
return value;
}
function assertProviderTextRanges(value, options) {
const result = assertPlainRecord(value, `${options.label} result`);
const items = result[options.property];
if (!Array.isArray(items) || items.length > 64) {
throw new TypeError(`${options.label} result is invalid`);
}
const line = options.payload?.line;
if (typeof line !== "string" || line.length > 8_192) {
throw new TypeError(`${options.label} payload line is invalid`);
}
for (const candidate of items) {
const item = assertPlainRecord(candidate, `${options.label} item`);
if (!Number.isInteger(item.start)
|| !Number.isInteger(item.length)
|| item.start < 0
|| item.length < 1
|| item.start > line.length
|| item.length > line.length - item.start) {
throw new TypeError(`${options.label} range is invalid`);
}
options.assertItem(item);
}
}
function assertOptionalColor(value, label) {
if (value == null) return;
const color = assertVisibleString(value, label, 9);
if (!/^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u.test(color)) {
throw new TypeError(`${label} is invalid`);
}
}
function assertAnnotations(value, label) {
if (!Array.isArray(value) || value.length > 8) throw new TypeError(`${label} is invalid`);
for (const candidate of value) {
const annotation = assertPlainRecord(candidate, `${label} item`);
assertVisibleString(annotation.text, `${label} text`, 512);
assertOptionalColor(annotation.color, `${label} color`);
}
}
function assertMatcherLines(payload) {
if (!Array.isArray(payload?.lines) || payload.lines.length < 1 || payload.lines.length > 32) {
throw new TypeError("Terminal matcher payload lines are invalid");
}
const lines = new Map();
let characterCount = 0;
for (const candidate of payload.lines) {
const item = assertPlainRecord(candidate, "Terminal matcher payload line");
const lineId = assertVisibleString(item.lineId, "Terminal matcher line ID", 64);
const line = assertVisibleString(item.line, "Terminal matcher line", 8_192);
characterCount += line.length;
if (characterCount > 96 * 1024) {
throw new TypeError("Terminal matcher payload exceeds its line budget");
}
if (lines.has(lineId)) throw new TypeError("Terminal matcher line IDs must be unique");
if (!Number.isInteger(item.bufferLineNumber) || item.bufferLineNumber < 1) {
throw new TypeError("Terminal matcher buffer line number is invalid");
}
lines.set(lineId, line);
}
return lines;
}
function assertCompletionResult(value) {
const result = assertPlainRecord(value, "Terminal completion result");
if (!Array.isArray(result.items) || result.items.length > 100) {
throw new TypeError("Terminal completion items are invalid");
}
for (const candidate of result.items) {
const item = assertPlainRecord(candidate, "Terminal completion item");
const text = assertVisibleString(item.text, "Terminal completion text", 4_096);
if (item.displayText != null
&& assertVisibleString(item.displayText, "Terminal completion display text", 4_096) !== text) {
throw new TypeError("Terminal completion display text must match the inserted text");
}
if (item.description != null) {
assertVisibleString(item.description, "Terminal completion description", 2_048);
}
if (item.score != null && (typeof item.score !== "number" || !Number.isFinite(item.score))) {
throw new TypeError("Terminal completion score is invalid");
}
}
}
function assertDecorationResult(value) {
const result = assertPlainRecord(value, "Terminal decoration result");
if (!Array.isArray(result.rules) || result.rules.length > 64) {
throw new TypeError("Terminal decoration rules are invalid");
}
for (const candidate of result.rules) {
const rule = assertPlainRecord(candidate, "Terminal decoration rule");
assertVisibleString(rule.id, "Terminal decoration rule ID", 128);
assertVisibleString(rule.label, "Terminal decoration rule label", 256);
if (!Array.isArray(rule.patterns) || rule.patterns.length < 1 || rule.patterns.length > 16) {
throw new TypeError("Terminal decoration rule patterns are invalid");
}
for (const pattern of rule.patterns) {
assertVisibleString(pattern, "Terminal decoration pattern", 512);
}
if (typeof rule.color !== "string") {
throw new TypeError("Terminal decoration rule color is required");
}
assertOptionalColor(rule.color, "Terminal decoration rule color");
}
}
function assertTerminalProviderPayload(kind, operation, payload) {
if (TERMINAL_PROVIDER_OPERATIONS.get(kind) !== operation) {
throw new TypeError("Terminal Provider operation does not match its kind");
}
if (kind === "terminal.completion") {
const input = assertVisibleString(payload?.input, "Terminal completion input", 4_096);
if (!Number.isInteger(payload.cursor) || payload.cursor < 0 || payload.cursor > input.length) {
throw new TypeError("Terminal completion cursor is invalid");
}
if (!["linux", "windows", "macos"].includes(payload.hostOs)) {
throw new TypeError("Terminal completion host OS is invalid");
}
if (payload.cwdSource != null && !["prompt", "fallback", "none"].includes(payload.cwdSource)) {
throw new TypeError("Terminal completion CWD source is invalid");
}
if (!Number.isInteger(payload.maximum) || payload.maximum < 1 || payload.maximum > 100) {
throw new TypeError("Terminal completion maximum is invalid");
}
}
if (kind === "terminal.decoration") {
assertVisibleString(payload?.reason, "Terminal decoration reason", 128);
}
if (kind === "terminal.link" || kind === "terminal.hover") {
assertVisibleString(payload?.line, "Terminal Provider line", 8_192);
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
throw new TypeError("Terminal Provider buffer line number is invalid");
}
}
if (kind === "terminal.matcher") assertMatcherLines(payload);
if (kind === "terminal.semantic") {
assertVisibleString(payload?.command, "Terminal semantic command", 4_096);
}
if (kind === "terminal.prompt") {
if (payload?.reason !== "commandCompleted") throw new TypeError("Terminal prompt reason is invalid");
if (payload.promptLine != null) {
assertVisibleString(payload.promptLine, "Terminal prompt line", 8_192);
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
throw new TypeError("Terminal prompt buffer line number is invalid");
}
} else if (payload.bufferLineNumber != null) {
throw new TypeError("Terminal prompt line number requires a prompt line");
}
}
if (kind === "terminal.background") {
assertVisibleString(payload?.reason, "Terminal background reason", 128);
assertOptionalColor(payload.terminalBackground, "Terminal background color");
}
if (kind === "terminal.theme") {
assertVisibleString(payload?.reason, "Terminal theme reason", 128);
const currentTheme = assertPlainRecord(payload?.currentTheme, "Current terminal theme");
if (currentTheme.type !== "dark" && currentTheme.type !== "light") {
throw new TypeError("Current terminal theme type is invalid");
}
const colors = assertPlainRecord(currentTheme.colors, "Current terminal theme colors");
for (const key of TERMINAL_THEME_COLOR_KEYS) {
if (typeof colors[key] !== "string") throw new TypeError(`Current terminal theme ${key} is required`);
assertOptionalColor(colors[key], `Current terminal theme ${key}`);
}
if (Object.keys(colors).some((key) => !TERMINAL_THEME_COLOR_KEY_SET.has(key))) {
throw new TypeError("Current terminal theme contains an unknown color");
}
}
}
function assertTerminalProviderOkResult(kind, value, payload) {
if (kind === "terminal.completion") assertCompletionResult(value);
if (kind === "terminal.decoration") assertDecorationResult(value);
if (kind === "terminal.link") {
assertProviderTextRanges(value, {
label: "Terminal link",
property: "links",
payload,
assertItem(item) {
const uri = assertVisibleString(item.uri, "Terminal link URI", 2_048);
const parsed = new URL(uri);
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:")
|| parsed.username || parsed.password) {
throw new TypeError("Terminal link URI is invalid");
}
if (item.label != null) assertVisibleString(item.label, "Terminal link label", 256);
},
});
}
if (kind === "terminal.hover") {
assertProviderTextRanges(value, {
label: "Terminal hover",
property: "hovers",
payload,
assertItem(item) {
assertVisibleString(item.contents, "Terminal hover contents", 2_048);
},
});
}
if (kind === "terminal.matcher") {
const result = assertPlainRecord(value, "Terminal matcher result");
const lines = assertMatcherLines(payload);
if (!Array.isArray(result.matches) || result.matches.length > 64) {
throw new TypeError("Terminal matcher result is invalid");
}
for (const candidate of result.matches) {
const item = assertPlainRecord(candidate, "Terminal matcher item");
const lineId = assertVisibleString(item.lineId, "Terminal matcher line ID", 64);
const line = lines.get(lineId);
if (line === undefined
|| !Number.isInteger(item.start)
|| !Number.isInteger(item.length)
|| item.start < 0
|| item.length < 1
|| item.start > line.length
|| item.length > line.length - item.start) {
throw new TypeError("Terminal matcher range is invalid");
}
assertVisibleString(item.label, "Terminal matcher label", 256);
if (item.severity != null && !["info", "warning", "error", "success"].includes(item.severity)) {
throw new TypeError("Terminal matcher severity is invalid");
}
assertOptionalColor(item.color, "Terminal matcher color");
}
}
if (kind === "terminal.semantic") {
const result = assertPlainRecord(value, "Terminal semantic result");
const command = payload?.command;
assertVisibleString(command, "Terminal semantic command", 4_096);
if (result.classification != null) {
assertVisibleString(result.classification, "Terminal semantic classification", 128);
}
if (result.description != null) {
assertVisibleString(result.description, "Terminal semantic description", 1_024);
}
if (result.destructive != null && typeof result.destructive !== "boolean") {
throw new TypeError("Terminal semantic destructive flag is invalid");
}
if (result.idempotent != null && typeof result.idempotent !== "boolean") {
throw new TypeError("Terminal semantic idempotent flag is invalid");
}
if (result.annotations != null) assertAnnotations(result.annotations, "Terminal semantic annotations");
}
if (kind === "terminal.prompt") {
const result = assertPlainRecord(value, "Terminal prompt result");
if (payload?.promptLine != null) {
assertVisibleString(payload.promptLine, "Terminal prompt line", 8_192);
if (!Number.isInteger(payload.bufferLineNumber) || payload.bufferLineNumber < 1) {
throw new TypeError("Terminal prompt buffer line number is invalid");
}
}
assertAnnotations(result.annotations, "Terminal prompt annotations");
}
if (kind === "terminal.background") {
const result = assertPlainRecord(value, "Terminal background result");
if (result.refreshAfterMs != null
&& (!Number.isInteger(result.refreshAfterMs)
|| result.refreshAfterMs < 250
|| result.refreshAfterMs > 60_000)) {
throw new TypeError("Terminal background refresh cadence is invalid");
}
if (!Array.isArray(result.layers) || result.layers.length > 4) {
throw new TypeError("Terminal background layers are invalid");
}
for (const candidate of result.layers) {
const layer = assertPlainRecord(candidate, "Terminal background layer");
assertVisibleString(layer.id, "Terminal background layer ID", 128);
if (typeof layer.color !== "string") {
throw new TypeError("Terminal background layer color is required");
}
assertOptionalColor(layer.color, "Terminal background layer color");
if (layer.opacity != null
&& (typeof layer.opacity !== "number"
|| !Number.isFinite(layer.opacity)
|| layer.opacity < 0
|| layer.opacity > 0.35)) {
throw new TypeError("Terminal background layer opacity is invalid");
}
}
}
if (kind === "terminal.theme") {
const result = assertPlainRecord(value, "Terminal theme result");
const colors = assertPlainRecord(result.colors, "Terminal theme colors");
if (Object.keys(result).some((key) => key !== "colors")
|| Object.keys(colors).some((key) => !TERMINAL_THEME_COLOR_KEY_SET.has(key))) {
throw new TypeError("Terminal theme result contains an unknown property");
}
for (const [key, color] of Object.entries(colors)) {
assertOptionalColor(color, `Terminal theme ${key}`);
}
}
}
function providerFailure(requestId, error) {
if (error instanceof PluginRpcError && error.code === RPC_ERRORS.cancelled) {
return Object.freeze({ requestId, status: "cancelled" });
}
return freezeJson({
requestId,
status: "failed",
error: {
code: error instanceof PluginRpcError ? error.code : RPC_ERRORS.internal,
message: String(error?.message ?? "Plugin Provider failed").slice(0, 2_048) || "Plugin Provider failed",
},
});
}
function normalizeActivationIdentity(activation) {
const identity = activation?.identity;
if (!identity
|| typeof identity.pluginId !== "string"
|| identity.pluginId !== activation.plugin.id
|| typeof identity.pluginVersion !== "string"
|| identity.pluginVersion !== activation.plugin.activeVersion
|| typeof identity.runtimeId !== "string"
|| identity.runtimeId.length < 1
|| typeof identity.runtimeKind !== "string"
|| identity.runtimeKind.length < 1
|| typeof identity.securityPrincipal !== "string"
|| identity.securityPrincipal.length < 1) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin Provider activation identity is unavailable or stale");
}
return Object.freeze({
pluginId: identity.pluginId,
pluginVersion: identity.pluginVersion,
runtimeId: identity.runtimeId,
runtimeKind: identity.runtimeKind,
securityPrincipal: identity.securityPrincipal,
});
}
function runtimeIdentityMatches(current, expected) {
return current?.pluginId === expected.pluginId
&& current?.pluginVersion === expected.pluginVersion
&& current?.runtimeId === expected.runtimeId
&& current?.runtimeKind === expected.runtimeKind
&& current?.securityPrincipal === expected.securityPrincipal;
}
class PluginTerminalProviderService {
constructor(options) {
if (!options?.contributionService || !options?.runtimeSupervisor || !options?.permissionEngine) {
throw new TypeError("Terminal Provider service requires contribution, runtime, and permission services");
}
this.contributionService = options.contributionService;
this.runtimeSupervisor = options.runtimeSupervisor;
this.permissionEngine = options.permissionEngine;
this.lifecycleAuthorizations = new Map();
this.contributionService.onDidChange?.(() => {
this.pruneLifecycleAuthorizations(this.activeTerminalProviderPluginIds());
});
}
lifecycleAuthorizationKey(pluginId, sessionId) {
return `${pluginId}\0${sessionId}`;
}
activeTerminalProviderPluginIds() {
const pluginIds = new Set();
for (const entry of this.contributionService.listProviders()) {
if (TERMINAL_PROVIDER_KIND_SET.has(entry.provider.kind)) pluginIds.add(entry.pluginId);
}
return pluginIds;
}
pruneLifecycleAuthorizations(pluginIds, sessionId = null) {
for (const [key, authorization] of this.lifecycleAuthorizations) {
if ((sessionId == null || authorization.sessionId === sessionId)
&& !pluginIds.has(authorization.identity.pluginId)) {
this.lifecycleAuthorizations.delete(key);
}
}
}
rememberLifecycleAuthorization(activation, identity, sessionId) {
this.lifecycleAuthorizations.set(
this.lifecycleAuthorizationKey(activation.plugin.id, sessionId),
Object.freeze({
context: Object.freeze({
pluginId: activation.plugin.id,
pluginVersion: activation.plugin.activeVersion,
runtimeId: identity.runtimeId,
runtimeKind: identity.runtimeKind,
manifest: activation.plugin.manifest,
securityPrincipal: identity.securityPrincipal,
}),
identity,
sessionId,
}),
);
}
listProviders(options = {}) {
const kind = assertTerminalProviderKind(options.kind);
const preferredProviderIds = options.preferredProviderIds ?? [];
if (!Array.isArray(preferredProviderIds)
|| preferredProviderIds.length > 256
|| preferredProviderIds.some((id) => typeof id !== "string" || id.length < 1 || id.length > 256)
|| new Set(preferredProviderIds).size !== preferredProviderIds.length) {
throw invalidArgument("Preferred Terminal Provider IDs are invalid");
}
if (options.locale != null
&& (typeof options.locale !== "string" || options.locale.length < 1 || options.locale.length > 128)) {
throw invalidArgument("Terminal Provider locale is invalid");
}
const preferred = new Map(preferredProviderIds.map((id, index) => [id, index]));
const providers = [...this.contributionService.listProviders({ kind, locale: options.locale })];
providers.sort((left, right) => {
const leftRank = preferred.get(left.provider.id) ?? Number.MAX_SAFE_INTEGER;
const rightRank = preferred.get(right.provider.id) ?? Number.MAX_SAFE_INTEGER;
return leftRank - rightRank
|| left.pluginId.localeCompare(right.pluginId)
|| left.provider.id.localeCompare(right.provider.id);
});
return freezeJson(providers);
}
async invokeProvider(params, options = {}) {
const kind = assertTerminalProviderKind(params?.kind);
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
const request = {
providerId: params?.providerId,
operation: params?.operation,
requestId: params?.requestId ?? `provider-${randomUUID()}`,
...(params?.payload === undefined ? {} : { payload: assertBoundedJson(params.payload, "Provider payload") }),
deadlineMs,
};
try { assertProviderRequest(request); }
catch (error) { throw invalidArgument(error?.message ?? "Provider request is invalid"); }
throwIfCancelled(options.signal);
const activationOperation = Promise.resolve(
this.contributionService.activateProvider(request.providerId),
);
const activation = options.signal
? await raceWithAbort(activationOperation, options.signal)
: await activationOperation;
throwIfCancelled(options.signal);
if (activation.provider.kind !== kind) {
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin Provider kind changed during activation");
}
const identity = normalizeActivationIdentity(activation);
const invocationSession = request.payload && typeof request.payload === "object" && !Array.isArray(request.payload)
? request.payload.session
: null;
const permissionSession = invocationSession == null
? null
: normalizeTerminalSessionSnapshot(invocationSession);
let providerPermissionScope;
for (const permission of TERMINAL_PROVIDER_PERMISSIONS.get(kind) ?? []) {
const grant = await this.permissionEngine.authorize({
pluginId: activation.plugin.id,
pluginVersion: activation.plugin.activeVersion,
runtimeId: identity.runtimeId,
runtimeKind: identity.runtimeKind,
manifest: activation.plugin.manifest,
securityPrincipal: identity.securityPrincipal,
signal: options.signal,
}, {
permission,
resources: ["*"],
reason: `Use ${activation.provider.id} for ${kind}`,
operationId: `${kind}:${activation.provider.id}`,
...(permissionSession == null ? {} : { sessionId: permissionSession.sessionId }),
});
if (permission === "provider.terminal") providerPermissionScope = grant?.scope;
}
const expectedIdentity = { expectedIdentity: identity };
if (permissionSession != null) {
await this.runtimeSupervisor.notify(activation.plugin.id, "plugin.terminal.event", {
type: "snapshot",
session: permissionSession,
}, expectedIdentity);
}
const rawResult = await this.runtimeSupervisor.request(activation.plugin.id, "provider.invoke", {
...request,
kind,
}, {
signal: options.signal,
timeoutMs: deadlineMs,
...expectedIdentity,
});
let result;
try {
assertProviderResult(rawResult);
assertBoundedJson(rawResult, "Provider result");
if (rawResult.status === "ok") {
assertTerminalProviderOkResult(kind, rawResult.result, request.payload);
}
result = rawResult;
} catch (error) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, `Plugin Provider returned an invalid result: ${error?.message ?? error}`);
}
if (result.requestId !== request.requestId) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin Provider returned a mismatched request ID");
}
if (result.status === "ok" && permissionSession != null && providerPermissionScope !== "once") {
this.rememberLifecycleAuthorization(activation, identity, permissionSession.sessionId);
}
return freezeJson({
pluginId: activation.plugin.id,
pluginVersion: activation.plugin.activeVersion,
runtimeId: identity.runtimeId,
providerId: activation.provider.id,
kind,
...result,
});
}
async provide(params, options = {}) {
const kind = assertTerminalProviderKind(params?.kind);
const operation = assertBoundedString(params?.operation, "Terminal Provider operation", 128);
const session = normalizeTerminalSessionSnapshot(params?.session);
const providedPayload = params?.payload === undefined
? undefined
: assertBoundedJson(params.payload, "Provider payload");
const payload = freezeJson(providedPayload && typeof providedPayload === "object" && !Array.isArray(providedPayload)
? { ...providedPayload, session }
: { session, ...(providedPayload === undefined ? {} : { value: providedPayload }) });
assertBoundedJson(payload, "Provider request payload");
try { assertTerminalProviderPayload(kind, operation, payload); }
catch (error) { throw invalidArgument(error?.message ?? "Terminal Provider payload is invalid"); }
const providers = this.listProviders({
kind,
locale: params?.locale,
preferredProviderIds: params?.preferredProviderIds,
}).slice(0, MAX_TERMINAL_PROVIDERS_PER_REQUEST);
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
return freezeJson(await Promise.all(providers.map(async (entry) => {
const { provider } = entry;
const requestId = `provider-${randomUUID()}`;
try {
return await this.invokeProvider({
providerId: provider.id,
kind,
operation,
requestId,
payload,
deadlineMs,
}, options);
} catch (error) {
return freezeJson({
pluginId: entry.pluginId,
pluginVersion: entry.pluginVersion,
providerId: provider.id,
kind,
...providerFailure(requestId, options.signal?.aborted
? new PluginRpcError(RPC_ERRORS.cancelled, "Terminal Provider request was cancelled")
: error),
});
}
})));
}
async publishSessionEvent(event) {
const normalized = normalizeTerminalSessionEvent(event);
const sessionId = normalized.session.sessionId;
const pluginIds = this.activeTerminalProviderPluginIds();
this.pruneLifecycleAuthorizations(pluginIds, sessionId);
const deliveries = await Promise.all([...pluginIds].sort().map(async (pluginId) => {
const key = this.lifecycleAuthorizationKey(pluginId, sessionId);
const authorization = this.lifecycleAuthorizations.get(key);
if (!authorization) return Object.freeze({ pluginId, delivered: false });
const identity = this.runtimeSupervisor.getRuntimeIdentity(pluginId);
if (!runtimeIdentityMatches(identity, authorization.identity)) {
this.lifecycleAuthorizations.delete(key);
return Object.freeze({ pluginId, delivered: false });
}
try {
await this.permissionEngine.authorize(authorization.context, {
permission: "provider.terminal",
resources: ["*"],
reason: "Receive Terminal Provider lifecycle metadata",
operationId: `terminal.lifecycle:${sessionId}`,
sessionId,
interactive: false,
});
await this.runtimeSupervisor.notify(pluginId, "plugin.terminal.event", normalized, {
expectedIdentity: authorization.identity,
});
if (normalized.type === "disposed") this.lifecycleAuthorizations.delete(key);
return Object.freeze({ pluginId, delivered: true });
} catch {
this.lifecycleAuthorizations.delete(key);
return Object.freeze({ pluginId, delivered: false });
}
}));
if (normalized.type === "disposed") {
for (const [key, authorization] of this.lifecycleAuthorizations) {
if (authorization.sessionId === sessionId) this.lifecycleAuthorizations.delete(key);
}
}
return freezeJson(deliveries);
}
}
module.exports = {
DEFAULT_PROVIDER_DEADLINE_MS,
MAX_PROVIDER_JSON_BYTES,
MAX_TERMINAL_PROVIDERS_PER_REQUEST,
PluginTerminalProviderService,
TERMINAL_PROVIDER_KINDS,
TERMINAL_PROVIDER_PERMISSIONS,
assertTerminalProviderKind,
normalizeTerminalSessionEvent,
normalizeTerminalSessionSnapshot,
};