[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:
825
electron/plugins/terminalProviderService.cjs
Normal file
825
electron/plugins/terminalProviderService.cjs
Normal file
@@ -0,0 +1,825 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user