mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
952 lines
28 KiB
TypeScript
952 lines
28 KiB
TypeScript
/**
|
|
* The operations contract: what a plugin declares it can do, in one shape every
|
|
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
|
|
*
|
|
* Declarations are data in the manifest, so a surface can list and describe an
|
|
* action without loading the plugin's code. Handlers are registered at
|
|
* activation with ctx.operations.register and must match a declaration.
|
|
*
|
|
* No Node imports: the frontend, the CLI and Mobile read this file too.
|
|
*/
|
|
|
|
import { coerceSettingValue, validateSettingValue } from "./settings.js";
|
|
import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js";
|
|
|
|
export const OPERATION_RISKS = ["read", "change", "destructive"] as const;
|
|
export type OperationRisk = (typeof OPERATION_RISKS)[number];
|
|
|
|
export const OPERATION_SCOPES = ["host", "resource", "server"] as const;
|
|
export type OperationScope = (typeof OPERATION_SCOPES)[number];
|
|
|
|
export const OPERATION_HOST_ACCESS = [
|
|
"connect",
|
|
"view",
|
|
"edit",
|
|
"manage",
|
|
] as const;
|
|
export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number];
|
|
|
|
export const OPERATION_DRY_RUN = ["native", "describe"] as const;
|
|
/** native: the handler checks ctx.dryRun. describe: core reports its text. */
|
|
export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number];
|
|
|
|
export const OPERATION_FIELD_TYPES = [
|
|
"boolean",
|
|
"string",
|
|
"number",
|
|
"select",
|
|
"multiselect",
|
|
"secret",
|
|
"textarea",
|
|
"json",
|
|
"resource",
|
|
] as const;
|
|
export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number];
|
|
|
|
export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number];
|
|
|
|
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const;
|
|
export type SignalKind = (typeof SIGNAL_KINDS)[number];
|
|
|
|
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const;
|
|
export type SignalStatus = (typeof SIGNAL_STATUSES)[number];
|
|
|
|
export const RESOURCE_COLUMN_TYPES = [
|
|
"text",
|
|
"status",
|
|
"bytes",
|
|
"percent",
|
|
"time",
|
|
"tags",
|
|
] as const;
|
|
export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number];
|
|
|
|
/** One input or output of an action. A subset of a settings field, plus rules. */
|
|
export interface OperationField {
|
|
key: string;
|
|
type: OperationFieldType;
|
|
labelKey?: string;
|
|
descriptionKey?: string;
|
|
placeholderKey?: string;
|
|
default?: unknown;
|
|
options?: PluginSettingsOption[];
|
|
min?: number;
|
|
max?: number;
|
|
/** Key of a boolean field in the same list that must be on. */
|
|
requires?: string;
|
|
group?: string;
|
|
required?: boolean;
|
|
/** string and textarea only. A regular expression the whole value matches. */
|
|
pattern?: string;
|
|
/** Read action whose rows fill the choices, for a list known only at run time. */
|
|
optionsFrom?: string;
|
|
/** type "resource" only: the resource id whose list fills the picker. */
|
|
resource?: string;
|
|
/** Outputs only: dropped before a run is stored or shown. */
|
|
sensitive?: boolean;
|
|
}
|
|
|
|
export interface OperationWhen {
|
|
/** Host setting values that must match, such as { enableDocker: true }. */
|
|
settings?: Record<string, boolean | string | number>;
|
|
/** Lowercase OS names. A host with no known OS passes. */
|
|
os?: string[];
|
|
}
|
|
|
|
export interface OperationSurfaces {
|
|
/** Offered to the AI assistant. Off unless set. */
|
|
assistant?: boolean;
|
|
/** Offered over MCP. Off unless set. */
|
|
mcp?: boolean;
|
|
/** Set false to keep out of the command palette. */
|
|
palette?: boolean;
|
|
/** Set false to keep out of Automations. */
|
|
automations?: boolean;
|
|
/** Set false to keep out of the phone app. */
|
|
mobile?: boolean;
|
|
/** Set false to keep out of the CLI. */
|
|
cli?: boolean;
|
|
}
|
|
|
|
export interface OperationAction {
|
|
/** `<pluginId>.<name>`. */
|
|
id: string;
|
|
/** Bump when inputs change in a way saved references cannot follow. */
|
|
version?: number;
|
|
titleKey: string;
|
|
descriptionKey?: string;
|
|
icon?: string;
|
|
scope: OperationScope;
|
|
/** Required for scope "resource": the resource this acts on. */
|
|
resource?: string;
|
|
inputs?: OperationField[];
|
|
outputs?: OperationField[];
|
|
risk: OperationRisk;
|
|
/** One of this plugin's own permissions, short or full. */
|
|
permission: string;
|
|
/** Lowest share level. Required for host and resource scopes. */
|
|
hostAccess?: OperationHostAccess;
|
|
dryRun?: OperationDryRun;
|
|
/** A read action of this plugin that proves the result. */
|
|
check?: string;
|
|
/** An action of this plugin that reverses this one. */
|
|
undo?: string;
|
|
/** Safe to run twice, so a rollout may retry it. */
|
|
idempotent?: boolean;
|
|
/** Only one such action per host at a time. */
|
|
exclusive?: boolean;
|
|
timeoutMs?: number;
|
|
when?: OperationWhen;
|
|
surfaces?: OperationSurfaces;
|
|
}
|
|
|
|
export interface OperationEvent {
|
|
id: string;
|
|
titleKey: string;
|
|
descriptionKey?: string;
|
|
severity?: OperationSeverity;
|
|
scope: "host" | "server";
|
|
payload?: OperationField[];
|
|
}
|
|
|
|
export interface ResourceColumn {
|
|
key: string;
|
|
titleKey: string;
|
|
type?: ResourceColumnType;
|
|
}
|
|
|
|
export interface OperationResource {
|
|
id: string;
|
|
titleKey: string;
|
|
icon?: string;
|
|
/** Read action that returns { rows: [...] } for one host. */
|
|
list: string;
|
|
/** Row field that is unique on a host. */
|
|
key: string;
|
|
/** Row field shown as the name. */
|
|
name: string;
|
|
/** Row field holding a status word. */
|
|
status?: string;
|
|
/** Row field holding a link, for auto links on the homepage. */
|
|
url?: string;
|
|
/** Tab type that opens this on a host. */
|
|
tab?: string;
|
|
columns?: ResourceColumn[];
|
|
filters?: string[];
|
|
}
|
|
|
|
export interface OperationSignal {
|
|
id: string;
|
|
titleKey: string;
|
|
/** Read action that returns a SignalResult. */
|
|
read: string;
|
|
scope: "host" | "server";
|
|
kind: SignalKind;
|
|
refreshSec?: number;
|
|
/** Action offered as the fix when this signal is not ok. */
|
|
fix?: string;
|
|
}
|
|
|
|
export interface PluginOperationsContribution {
|
|
actions?: OperationAction[];
|
|
events?: OperationEvent[];
|
|
resources?: OperationResource[];
|
|
signals?: OperationSignal[];
|
|
/**
|
|
* Actions this plugin may run through ctx.operations.invoke: exact ids,
|
|
* `<pluginId>.*`, or `*`.
|
|
*/
|
|
invokes?: string[];
|
|
}
|
|
|
|
/** What a signal's read action returns. */
|
|
export interface SignalResult {
|
|
value?: number | string | null;
|
|
status: SignalStatus;
|
|
label?: string;
|
|
items?: { label: string; status?: SignalStatus }[];
|
|
link?: { tab?: string; data?: Record<string, unknown> };
|
|
}
|
|
|
|
/** What a resource's list action returns. */
|
|
export interface ResourceListResult {
|
|
rows: Record<string, unknown>[];
|
|
}
|
|
|
|
/** What a handler returns. */
|
|
export interface OperationResult {
|
|
/** One plain line for history and per host results. */
|
|
summary: string;
|
|
/** Whether anything actually changed. */
|
|
changed?: boolean;
|
|
data?: unknown;
|
|
/** Input for the `undo` action that reverses this exact run. */
|
|
undoInput?: Record<string, unknown>;
|
|
}
|
|
|
|
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
|
|
|
|
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
|
|
|
|
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
|
|
export const REDACTED_VALUE = "[redacted]";
|
|
|
|
const ALLOWED_OPERATIONS = [
|
|
"actions",
|
|
"events",
|
|
"resources",
|
|
"signals",
|
|
"invokes",
|
|
];
|
|
const ALLOWED_ACTION = [
|
|
"id",
|
|
"version",
|
|
"titleKey",
|
|
"descriptionKey",
|
|
"icon",
|
|
"scope",
|
|
"resource",
|
|
"inputs",
|
|
"outputs",
|
|
"risk",
|
|
"permission",
|
|
"hostAccess",
|
|
"dryRun",
|
|
"check",
|
|
"undo",
|
|
"idempotent",
|
|
"exclusive",
|
|
"timeoutMs",
|
|
"when",
|
|
"surfaces",
|
|
];
|
|
const ALLOWED_FIELD = [
|
|
"key",
|
|
"type",
|
|
"labelKey",
|
|
"descriptionKey",
|
|
"placeholderKey",
|
|
"default",
|
|
"options",
|
|
"min",
|
|
"max",
|
|
"requires",
|
|
"group",
|
|
"required",
|
|
"pattern",
|
|
"optionsFrom",
|
|
"resource",
|
|
"sensitive",
|
|
];
|
|
const ALLOWED_EVENT = [
|
|
"id",
|
|
"titleKey",
|
|
"descriptionKey",
|
|
"severity",
|
|
"scope",
|
|
"payload",
|
|
];
|
|
const ALLOWED_RESOURCE = [
|
|
"id",
|
|
"titleKey",
|
|
"icon",
|
|
"list",
|
|
"key",
|
|
"name",
|
|
"status",
|
|
"url",
|
|
"tab",
|
|
"columns",
|
|
"filters",
|
|
];
|
|
const ALLOWED_SIGNAL = [
|
|
"id",
|
|
"titleKey",
|
|
"read",
|
|
"scope",
|
|
"kind",
|
|
"refreshSec",
|
|
"fix",
|
|
];
|
|
const ALLOWED_SURFACES = [
|
|
"assistant",
|
|
"mcp",
|
|
"palette",
|
|
"automations",
|
|
"mobile",
|
|
"cli",
|
|
];
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function rejectUnknown(
|
|
raw: Record<string, unknown>,
|
|
allowed: string[],
|
|
where: string,
|
|
errors: string[],
|
|
): void {
|
|
for (const key of Object.keys(raw)) {
|
|
if (!allowed.includes(key))
|
|
errors.push(`Unknown field "${key}" in ${where}`);
|
|
}
|
|
}
|
|
|
|
function requireEnum(
|
|
value: unknown,
|
|
allowed: readonly string[],
|
|
where: string,
|
|
errors: string[],
|
|
): void {
|
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
function requireText(value: unknown, where: string, errors: string[]): void {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
errors.push(`${where} must be a non-empty string`);
|
|
}
|
|
}
|
|
|
|
function optionalText(value: unknown, where: string, errors: string[]): void {
|
|
if (value !== undefined) requireText(value, where, errors);
|
|
}
|
|
|
|
function optionalBoolean(
|
|
value: unknown,
|
|
where: string,
|
|
errors: string[],
|
|
): void {
|
|
if (value !== undefined && typeof value !== "boolean") {
|
|
errors.push(`${where} must be a boolean`);
|
|
}
|
|
}
|
|
|
|
function requireId(
|
|
value: unknown,
|
|
pluginId: string | undefined,
|
|
where: string,
|
|
seen: Set<string>,
|
|
errors: string[],
|
|
): void {
|
|
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
|
|
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
|
|
return;
|
|
}
|
|
if (pluginId && !value.startsWith(`${pluginId}.`)) {
|
|
errors.push(`${where}.id must start with "${pluginId}."`);
|
|
}
|
|
if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`);
|
|
seen.add(value);
|
|
}
|
|
|
|
function eachObject(
|
|
value: unknown,
|
|
where: string,
|
|
errors: string[],
|
|
each: (raw: Record<string, unknown>, at: string) => void,
|
|
): void {
|
|
if (value === undefined) return;
|
|
if (!Array.isArray(value)) {
|
|
errors.push(`${where} must be an array`);
|
|
return;
|
|
}
|
|
value.forEach((raw, index) => {
|
|
const at = `${where}[${index}]`;
|
|
if (!isObject(raw)) {
|
|
errors.push(`${at} must be an object`);
|
|
return;
|
|
}
|
|
each(raw, at);
|
|
});
|
|
}
|
|
|
|
function validateFields(value: unknown, where: string, errors: string[]): void {
|
|
const keys = new Set<string>();
|
|
eachObject(value, where, errors, (raw, at) => {
|
|
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
|
|
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
|
|
errors.push(`${at}.key must be a short name starting with a letter`);
|
|
} else if (keys.has(raw.key)) {
|
|
errors.push(`${at}.key "${raw.key}" is declared twice`);
|
|
} else {
|
|
keys.add(raw.key);
|
|
}
|
|
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
|
|
optionalText(raw.labelKey, `${at}.labelKey`, errors);
|
|
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
|
|
optionalText(raw.group, `${at}.group`, errors);
|
|
optionalText(raw.requires, `${at}.requires`, errors);
|
|
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
|
|
optionalBoolean(raw.required, `${at}.required`, errors);
|
|
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
|
|
if (raw.type === "resource" && typeof raw.resource !== "string") {
|
|
errors.push(`${at}.resource is required for type "resource"`);
|
|
}
|
|
if (raw.resource !== undefined && raw.type !== "resource") {
|
|
errors.push(`${at}.resource needs type "resource"`);
|
|
}
|
|
if (raw.type === "secret" && raw.default !== undefined) {
|
|
errors.push(`${at}.default is not allowed on a secret`);
|
|
}
|
|
if (
|
|
(raw.type === "select" || raw.type === "multiselect") &&
|
|
!raw.optionsFrom &&
|
|
(!Array.isArray(raw.options) || raw.options.length === 0)
|
|
) {
|
|
errors.push(`${at} needs options or optionsFrom`);
|
|
}
|
|
for (const bound of ["min", "max"] as const) {
|
|
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
|
|
errors.push(`${at}.${bound} must be a number`);
|
|
}
|
|
}
|
|
if (raw.pattern !== undefined) {
|
|
if (typeof raw.pattern !== "string") {
|
|
errors.push(`${at}.pattern must be a string`);
|
|
} else {
|
|
try {
|
|
new RegExp(raw.pattern);
|
|
} catch {
|
|
errors.push(`${at}.pattern is not a valid regular expression`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateWhen(value: unknown, at: string, errors: string[]): void {
|
|
if (value === undefined) return;
|
|
if (!isObject(value)) {
|
|
errors.push(`${at} must be an object`);
|
|
return;
|
|
}
|
|
rejectUnknown(value, ["settings", "os"], at, errors);
|
|
if (value.settings !== undefined && !isObject(value.settings)) {
|
|
errors.push(`${at}.settings must be an object`);
|
|
}
|
|
if (
|
|
value.os !== undefined &&
|
|
(!Array.isArray(value.os) ||
|
|
value.os.some((entry) => typeof entry !== "string"))
|
|
) {
|
|
errors.push(`${at}.os must be an array of strings`);
|
|
}
|
|
}
|
|
|
|
function validateAction(
|
|
raw: Record<string, unknown>,
|
|
at: string,
|
|
errors: string[],
|
|
): void {
|
|
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
|
|
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
optionalText(raw.icon, `${at}.icon`, errors);
|
|
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
|
|
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
|
|
requireText(raw.permission, `${at}.permission`, errors);
|
|
validateFields(raw.inputs, `${at}.inputs`, errors);
|
|
validateFields(raw.outputs, `${at}.outputs`, errors);
|
|
validateWhen(raw.when, `${at}.when`, errors);
|
|
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
|
|
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
|
|
|
|
if (
|
|
raw.version !== undefined &&
|
|
(!Number.isInteger(raw.version) || (raw.version as number) < 1)
|
|
) {
|
|
errors.push(`${at}.version must be a whole number from 1`);
|
|
}
|
|
if (raw.dryRun !== undefined) {
|
|
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
|
|
}
|
|
if (
|
|
raw.timeoutMs !== undefined &&
|
|
(typeof raw.timeoutMs !== "number" ||
|
|
raw.timeoutMs <= 0 ||
|
|
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)
|
|
) {
|
|
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
|
|
}
|
|
if (raw.scope === "resource" && typeof raw.resource !== "string") {
|
|
errors.push(`${at}.resource is required for scope "resource"`);
|
|
}
|
|
if (raw.scope !== "resource" && raw.resource !== undefined) {
|
|
errors.push(`${at}.resource needs scope "resource"`);
|
|
}
|
|
if (raw.scope === "server" && raw.hostAccess !== undefined) {
|
|
errors.push(`${at}.hostAccess does not apply to scope "server"`);
|
|
}
|
|
if (raw.scope === "host" || raw.scope === "resource") {
|
|
requireEnum(
|
|
raw.hostAccess,
|
|
OPERATION_HOST_ACCESS,
|
|
`${at}.hostAccess`,
|
|
errors,
|
|
);
|
|
if (
|
|
raw.risk === "destructive" &&
|
|
(raw.hostAccess === "connect" || raw.hostAccess === "view")
|
|
) {
|
|
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
|
|
}
|
|
}
|
|
if (raw.surfaces !== undefined) {
|
|
if (!isObject(raw.surfaces)) {
|
|
errors.push(`${at}.surfaces must be an object`);
|
|
} else {
|
|
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
|
|
for (const [key, value] of Object.entries(raw.surfaces)) {
|
|
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateResource(
|
|
raw: Record<string, unknown>,
|
|
at: string,
|
|
errors: string[],
|
|
): void {
|
|
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
|
|
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
requireText(raw.list, `${at}.list`, errors);
|
|
requireText(raw.key, `${at}.key`, errors);
|
|
requireText(raw.name, `${at}.name`, errors);
|
|
optionalText(raw.icon, `${at}.icon`, errors);
|
|
optionalText(raw.status, `${at}.status`, errors);
|
|
optionalText(raw.url, `${at}.url`, errors);
|
|
optionalText(raw.tab, `${at}.tab`, errors);
|
|
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
|
|
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
|
|
requireText(column.key, `${where}.key`, errors);
|
|
requireText(column.titleKey, `${where}.titleKey`, errors);
|
|
if (column.type !== undefined) {
|
|
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
|
|
}
|
|
});
|
|
if (
|
|
raw.filters !== undefined &&
|
|
(!Array.isArray(raw.filters) ||
|
|
raw.filters.some((entry) => typeof entry !== "string"))
|
|
) {
|
|
errors.push(`${at}.filters must be an array of strings`);
|
|
}
|
|
}
|
|
|
|
/** Shape checks for contributes.operations, run by validateManifest. */
|
|
export function validateOperations(
|
|
value: unknown,
|
|
pluginId: string | undefined,
|
|
errors: string[],
|
|
): void {
|
|
if (value === undefined) return;
|
|
const root = "contributes.operations";
|
|
if (!isObject(value)) {
|
|
errors.push(`Field "${root}" must be an object`);
|
|
return;
|
|
}
|
|
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
|
|
|
|
const ids = new Set<string>();
|
|
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
|
|
requireId(raw.id, pluginId, at, ids, errors);
|
|
validateAction(raw, at, errors);
|
|
});
|
|
|
|
const eventIds = new Set<string>();
|
|
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
|
|
requireId(raw.id, pluginId, at, eventIds, errors);
|
|
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
|
|
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
|
|
if (raw.severity !== undefined) {
|
|
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
|
|
}
|
|
validateFields(raw.payload, `${at}.payload`, errors);
|
|
});
|
|
|
|
const resourceIds = new Set<string>();
|
|
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
|
|
requireId(raw.id, pluginId, at, resourceIds, errors);
|
|
validateResource(raw, at, errors);
|
|
});
|
|
|
|
const signalIds = new Set<string>();
|
|
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
|
|
requireId(raw.id, pluginId, at, signalIds, errors);
|
|
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
|
|
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
requireText(raw.read, `${at}.read`, errors);
|
|
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
|
|
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
|
|
optionalText(raw.fix, `${at}.fix`, errors);
|
|
if (
|
|
raw.refreshSec !== undefined &&
|
|
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)
|
|
) {
|
|
errors.push(`${at}.refreshSec must be at least 10`);
|
|
}
|
|
});
|
|
|
|
if (value.invokes !== undefined) {
|
|
if (
|
|
!Array.isArray(value.invokes) ||
|
|
value.invokes.some(
|
|
(entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry),
|
|
)
|
|
) {
|
|
errors.push(
|
|
`${root}.invokes must list action ids, "<pluginId>.*" or "*"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The cross-field rules: every id one declaration names must exist in this
|
|
* plugin, or a surface would offer a button that goes nowhere.
|
|
*/
|
|
export function checkOperationReferences(
|
|
operations: PluginOperationsContribution | undefined,
|
|
capabilities: readonly string[],
|
|
hasPermission: (name: string) => boolean,
|
|
errors: string[],
|
|
): void {
|
|
if (!operations) return;
|
|
const root = "contributes.operations";
|
|
const actions = new Map(
|
|
(operations.actions ?? []).map((action) => [action.id, action]),
|
|
);
|
|
const resources = new Set((operations.resources ?? []).map((r) => r.id));
|
|
|
|
const declaresAnything =
|
|
actions.size > 0 ||
|
|
(operations.events?.length ?? 0) > 0 ||
|
|
resources.size > 0 ||
|
|
(operations.signals?.length ?? 0) > 0;
|
|
if (declaresAnything && !capabilities.includes("operations:provide")) {
|
|
errors.push(`${root} needs the "operations:provide" capability`);
|
|
}
|
|
if (
|
|
operations.invokes?.length &&
|
|
!capabilities.includes("operations:invoke")
|
|
) {
|
|
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
|
|
}
|
|
|
|
const readAction = (id: string, where: string) => {
|
|
const target = actions.get(id);
|
|
if (!target) errors.push(`${where} "${id}" is not a declared action`);
|
|
else if (target.risk !== "read") {
|
|
errors.push(`${where} "${id}" must be a read action`);
|
|
}
|
|
};
|
|
|
|
for (const action of actions.values()) {
|
|
const at = `${root}.actions["${action.id}"]`;
|
|
if (!hasPermission(action.permission)) {
|
|
errors.push(
|
|
`${at}.permission "${action.permission}" is not declared in contributes.permissions`,
|
|
);
|
|
}
|
|
if (action.resource && !resources.has(action.resource)) {
|
|
errors.push(`${at}.resource "${action.resource}" is not declared`);
|
|
}
|
|
if (action.check) readAction(action.check, `${at}.check`);
|
|
if (action.undo && !actions.has(action.undo)) {
|
|
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
|
|
}
|
|
for (const field of action.inputs ?? []) {
|
|
if (field.optionsFrom) {
|
|
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
|
|
}
|
|
if (field.resource && !resources.has(field.resource)) {
|
|
errors.push(`${at} input "${field.key}" names an unknown resource`);
|
|
}
|
|
}
|
|
}
|
|
for (const resource of operations.resources ?? []) {
|
|
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
|
|
}
|
|
for (const signal of operations.signals ?? []) {
|
|
const at = `${root}.signals["${signal.id}"]`;
|
|
readAction(signal.read, `${at}.read`);
|
|
if (signal.fix && !actions.has(signal.fix)) {
|
|
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Whether a plugin's invokes list lets it run an action. */
|
|
export function invokeAllowed(
|
|
invokes: readonly string[] | undefined,
|
|
actionId: string,
|
|
): boolean {
|
|
for (const entry of invokes ?? []) {
|
|
if (entry === "*" || entry === actionId) return true;
|
|
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const RISK_RANK: Record<OperationRisk, number> = {
|
|
read: 0,
|
|
change: 1,
|
|
destructive: 2,
|
|
};
|
|
|
|
export function riskRank(risk: OperationRisk): number {
|
|
return RISK_RANK[risk];
|
|
}
|
|
|
|
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
|
|
export function onSurface(
|
|
action: Pick<OperationAction, "surfaces">,
|
|
surface: keyof OperationSurfaces,
|
|
): boolean {
|
|
const value = action.surfaces?.[surface];
|
|
if (surface === "assistant" || surface === "mcp") return value === true;
|
|
return value !== false;
|
|
}
|
|
|
|
/** Whether an action applies to a host, from fields any client can read. */
|
|
export function matchesWhen(
|
|
when: OperationWhen | undefined,
|
|
host: { settings?: Record<string, unknown>; os?: string | null },
|
|
): boolean {
|
|
if (!when) return true;
|
|
for (const [key, expected] of Object.entries(when.settings ?? {})) {
|
|
if (host.settings?.[key] !== expected) return false;
|
|
}
|
|
if (when.os?.length && host.os) {
|
|
if (!when.os.includes(host.os.toLowerCase())) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function asSettingsField(field: OperationField): PluginSettingsField {
|
|
const type = field.type === "resource" ? "string" : field.type;
|
|
return { ...field, type } as PluginSettingsField;
|
|
}
|
|
|
|
function isEmpty(value: unknown): boolean {
|
|
return value === undefined || value === null || value === "";
|
|
}
|
|
|
|
export interface OperationInputResult {
|
|
values: Record<string, unknown>;
|
|
errors: string[];
|
|
}
|
|
|
|
/**
|
|
* Turns raw input into validated values. Keys no field declares are refused,
|
|
* so nothing a handler did not ask for ever reaches it.
|
|
*/
|
|
export function parseOperationInput(
|
|
fields: readonly OperationField[] | undefined,
|
|
raw: unknown,
|
|
): OperationInputResult {
|
|
const errors: string[] = [];
|
|
const values: Record<string, unknown> = {};
|
|
const list = fields ?? [];
|
|
|
|
if (raw !== undefined && raw !== null && !isObject(raw)) {
|
|
return { values, errors: ["Input must be an object"] };
|
|
}
|
|
const input = (raw ?? {}) as Record<string, unknown>;
|
|
for (const key of Object.keys(input)) {
|
|
if (!list.some((field) => field.key === key)) {
|
|
errors.push(`"${key}" is not an input of this action`);
|
|
}
|
|
}
|
|
|
|
for (const field of list) {
|
|
if (field.requires && input[field.requires] !== true) continue;
|
|
|
|
let value = input[field.key];
|
|
if (isEmpty(value)) {
|
|
if (field.default !== undefined) {
|
|
values[field.key] = field.default;
|
|
} else if (field.required) {
|
|
errors.push(`"${field.key}" is required`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const settingsField = asSettingsField(field);
|
|
value = coerceSettingValue(settingsField, value);
|
|
|
|
if (field.type === "secret" && typeof value !== "string") {
|
|
errors.push(`"${field.key}" must be a string`);
|
|
continue;
|
|
}
|
|
if (field.type === "json" && typeof value === "string") {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch {
|
|
errors.push(`"${field.key}" must be valid JSON`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// A list known only at run time is checked by the handler.
|
|
const skipChoices =
|
|
field.optionsFrom &&
|
|
(field.type === "select" || field.type === "multiselect");
|
|
const problem = skipChoices
|
|
? field.type === "select"
|
|
? typeof value === "string"
|
|
? null
|
|
: `"${field.key}" must be a string`
|
|
: Array.isArray(value)
|
|
? null
|
|
: `"${field.key}" must be an array`
|
|
: validateSettingValue(settingsField, value);
|
|
if (problem) {
|
|
errors.push(problem);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
field.pattern &&
|
|
typeof value === "string" &&
|
|
!new RegExp(`^(?:${field.pattern})$`).test(value)
|
|
) {
|
|
errors.push(`"${field.key}" has the wrong format`);
|
|
continue;
|
|
}
|
|
values[field.key] = value;
|
|
}
|
|
|
|
return { values, errors };
|
|
}
|
|
|
|
/** A copy safe to store or show: secret and sensitive values are replaced. */
|
|
export function redactFields(
|
|
fields: readonly OperationField[] | undefined,
|
|
values: unknown,
|
|
): unknown {
|
|
if (!isObject(values)) return values;
|
|
const copy: Record<string, unknown> = { ...values };
|
|
for (const field of fields ?? []) {
|
|
if (field.type !== "secret" && !field.sensitive) continue;
|
|
if (field.key in copy) copy[field.key] = REDACTED_VALUE;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
/** Trims text to a byte-friendly length with a plain marker. */
|
|
export function capText(text: string, maxLength: number): string {
|
|
return text.length <= maxLength
|
|
? text
|
|
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
|
|
}
|
|
|
|
function fieldSchema(field: OperationField): Record<string, unknown> {
|
|
const schema: Record<string, unknown> = {};
|
|
if (field.default !== undefined) schema.default = field.default;
|
|
const values = field.optionsFrom
|
|
? undefined
|
|
: field.options?.map((option) => option.value);
|
|
|
|
switch (field.type) {
|
|
case "boolean":
|
|
schema.type = "boolean";
|
|
break;
|
|
case "number":
|
|
schema.type = "number";
|
|
if (field.min !== undefined) schema.minimum = field.min;
|
|
if (field.max !== undefined) schema.maximum = field.max;
|
|
break;
|
|
case "select":
|
|
schema.type = "string";
|
|
if (values) schema.enum = values;
|
|
break;
|
|
case "multiselect":
|
|
schema.type = "array";
|
|
schema.items = values
|
|
? { type: "string", enum: values }
|
|
: { type: "string" };
|
|
break;
|
|
case "json":
|
|
break;
|
|
default:
|
|
schema.type = "string";
|
|
if (field.pattern) schema.pattern = `^(?:${field.pattern})$`;
|
|
}
|
|
if (field.type === "resource" && field.resource) {
|
|
schema["x-termix-resource"] = field.resource;
|
|
}
|
|
if (field.type === "secret") schema.writeOnly = true;
|
|
return schema;
|
|
}
|
|
|
|
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
|
|
export function toJsonSchema(
|
|
fields: readonly OperationField[] | undefined,
|
|
describe?: (field: OperationField) => string | undefined,
|
|
): Record<string, unknown> {
|
|
const properties: Record<string, unknown> = {};
|
|
const required: string[] = [];
|
|
for (const field of fields ?? []) {
|
|
const schema = fieldSchema(field);
|
|
const description = describe?.(field);
|
|
if (description) schema.description = description;
|
|
properties[field.key] = schema;
|
|
if (field.required && field.default === undefined && !field.requires) {
|
|
required.push(field.key);
|
|
}
|
|
}
|
|
return { type: "object", properties, required, additionalProperties: false };
|
|
}
|