@termix-ssh/plugin-sdk 1.0.21 → 1.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/PLUGIN-GUIDE.md +130 -3
- package/cli/commands/build.mjs +55 -36
- package/cli/commands/validate.mjs +4 -0
- package/dist/backend.d.ts +139 -7
- package/dist/capabilities.js +3 -0
- package/dist/frontend.d.ts +7 -0
- package/dist/host/{PluginsTab-HQGSDSHS.js → PluginsTab-MNQU5OJ3.js} +22 -14
- package/dist/host/{chunk-JFQGI4OD.js → chunk-6WLECSNB.js} +71 -12
- package/dist/host/chunk-BP42N374.js +53 -0
- package/dist/host/{chunk-WWFRBU2E.js → chunk-C52NRTA6.js} +82 -51
- package/dist/host/{chunk-GTHINW3L.js → chunk-DN6YKMFJ.js} +34650 -34968
- package/dist/host/chunk-GBBKHJRQ.js +1526 -0
- package/dist/host/{chunk-FGX27MAA.js → chunk-GRH7GSKG.js} +1 -1
- package/dist/host/chunk-IMPLAAUM.js +345 -0
- package/dist/host/{chunk-A5CULDWV.js → chunk-X65V7ZRW.js} +121 -1574
- package/dist/host/core-classes.json +1 -1
- package/dist/host/frame-host.css +2 -0
- package/dist/host/frame-host.js +242 -0
- package/dist/host/{linked-server-UDSFY3N7.js → linked-server-YCAJ2NX3.js} +1 -1
- package/dist/host/{rbac-api-AAAPMF52.js → rbac-api-A64TXDL5.js} +5 -1
- package/dist/host/testing-host.js +9 -6
- package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
- package/dist/host/types/src/types/index.d.ts +8 -0
- package/dist/host/types/src/ui/api/plugins-api.d.ts +2 -1
- package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
- package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
- package/dist/host/types/src/ui/lib/db-health-monitor.d.ts +5 -0
- package/dist/host/types/src/ui/main-axios.d.ts +1 -1
- package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
- package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
- package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
- package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
- package/dist/host/ui.js +357 -370
- package/dist/host/{use-permissions-VAND5GEG.js → use-permissions-FLQAN6I7.js} +1 -1
- package/dist/host-commands.d.ts +7 -0
- package/dist/host-commands.js +35 -5
- package/dist/manifest.d.ts +33 -1
- package/dist/manifest.js +114 -1
- package/dist/operations.d.ts +222 -0
- package/dist/operations.js +650 -0
- package/dist/testing.d.ts +9 -1
- package/dist/testing.js +45 -0
- package/package.json +9 -1
- package/schema/manifest.schema.json +67 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operations contract: what a plugin declares it can do, in one shape every
|
|
3
|
+
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
|
|
4
|
+
*
|
|
5
|
+
* Declarations are data in the manifest, so a surface can list and describe an
|
|
6
|
+
* action without loading the plugin's code. Handlers are registered at
|
|
7
|
+
* activation with ctx.operations.register and must match a declaration.
|
|
8
|
+
*
|
|
9
|
+
* No Node imports: the frontend, the CLI and Mobile read this file too.
|
|
10
|
+
*/
|
|
11
|
+
import { coerceSettingValue, validateSettingValue } from "./settings.js";
|
|
12
|
+
export const OPERATION_RISKS = ["read", "change", "destructive"];
|
|
13
|
+
export const OPERATION_SCOPES = ["host", "resource", "server"];
|
|
14
|
+
export const OPERATION_HOST_ACCESS = [
|
|
15
|
+
"connect",
|
|
16
|
+
"view",
|
|
17
|
+
"edit",
|
|
18
|
+
"manage",
|
|
19
|
+
];
|
|
20
|
+
export const OPERATION_DRY_RUN = ["native", "describe"];
|
|
21
|
+
export const OPERATION_FIELD_TYPES = [
|
|
22
|
+
"boolean",
|
|
23
|
+
"string",
|
|
24
|
+
"number",
|
|
25
|
+
"select",
|
|
26
|
+
"multiselect",
|
|
27
|
+
"secret",
|
|
28
|
+
"textarea",
|
|
29
|
+
"json",
|
|
30
|
+
"resource",
|
|
31
|
+
];
|
|
32
|
+
export const OPERATION_SEVERITIES = ["info", "warning", "critical"];
|
|
33
|
+
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"];
|
|
34
|
+
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"];
|
|
35
|
+
export const RESOURCE_COLUMN_TYPES = [
|
|
36
|
+
"text",
|
|
37
|
+
"status",
|
|
38
|
+
"bytes",
|
|
39
|
+
"percent",
|
|
40
|
+
"time",
|
|
41
|
+
"tags",
|
|
42
|
+
];
|
|
43
|
+
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
|
|
44
|
+
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
45
|
+
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
|
|
46
|
+
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
|
|
47
|
+
export const REDACTED_VALUE = "[redacted]";
|
|
48
|
+
const ALLOWED_OPERATIONS = [
|
|
49
|
+
"actions",
|
|
50
|
+
"events",
|
|
51
|
+
"resources",
|
|
52
|
+
"signals",
|
|
53
|
+
"invokes",
|
|
54
|
+
];
|
|
55
|
+
const ALLOWED_ACTION = [
|
|
56
|
+
"id",
|
|
57
|
+
"version",
|
|
58
|
+
"titleKey",
|
|
59
|
+
"descriptionKey",
|
|
60
|
+
"icon",
|
|
61
|
+
"scope",
|
|
62
|
+
"resource",
|
|
63
|
+
"inputs",
|
|
64
|
+
"outputs",
|
|
65
|
+
"risk",
|
|
66
|
+
"permission",
|
|
67
|
+
"hostAccess",
|
|
68
|
+
"dryRun",
|
|
69
|
+
"check",
|
|
70
|
+
"undo",
|
|
71
|
+
"idempotent",
|
|
72
|
+
"exclusive",
|
|
73
|
+
"timeoutMs",
|
|
74
|
+
"when",
|
|
75
|
+
"surfaces",
|
|
76
|
+
];
|
|
77
|
+
const ALLOWED_FIELD = [
|
|
78
|
+
"key",
|
|
79
|
+
"type",
|
|
80
|
+
"labelKey",
|
|
81
|
+
"descriptionKey",
|
|
82
|
+
"placeholderKey",
|
|
83
|
+
"default",
|
|
84
|
+
"options",
|
|
85
|
+
"min",
|
|
86
|
+
"max",
|
|
87
|
+
"requires",
|
|
88
|
+
"group",
|
|
89
|
+
"required",
|
|
90
|
+
"pattern",
|
|
91
|
+
"optionsFrom",
|
|
92
|
+
"resource",
|
|
93
|
+
"sensitive",
|
|
94
|
+
];
|
|
95
|
+
const ALLOWED_EVENT = [
|
|
96
|
+
"id",
|
|
97
|
+
"titleKey",
|
|
98
|
+
"descriptionKey",
|
|
99
|
+
"severity",
|
|
100
|
+
"scope",
|
|
101
|
+
"payload",
|
|
102
|
+
];
|
|
103
|
+
const ALLOWED_RESOURCE = [
|
|
104
|
+
"id",
|
|
105
|
+
"titleKey",
|
|
106
|
+
"icon",
|
|
107
|
+
"list",
|
|
108
|
+
"key",
|
|
109
|
+
"name",
|
|
110
|
+
"status",
|
|
111
|
+
"url",
|
|
112
|
+
"tab",
|
|
113
|
+
"columns",
|
|
114
|
+
"filters",
|
|
115
|
+
];
|
|
116
|
+
const ALLOWED_SIGNAL = [
|
|
117
|
+
"id",
|
|
118
|
+
"titleKey",
|
|
119
|
+
"read",
|
|
120
|
+
"scope",
|
|
121
|
+
"kind",
|
|
122
|
+
"refreshSec",
|
|
123
|
+
"fix",
|
|
124
|
+
];
|
|
125
|
+
const ALLOWED_SURFACES = [
|
|
126
|
+
"assistant",
|
|
127
|
+
"mcp",
|
|
128
|
+
"palette",
|
|
129
|
+
"automations",
|
|
130
|
+
"mobile",
|
|
131
|
+
"cli",
|
|
132
|
+
];
|
|
133
|
+
function isObject(value) {
|
|
134
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
135
|
+
}
|
|
136
|
+
function rejectUnknown(raw, allowed, where, errors) {
|
|
137
|
+
for (const key of Object.keys(raw)) {
|
|
138
|
+
if (!allowed.includes(key))
|
|
139
|
+
errors.push(`Unknown field "${key}" in ${where}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function requireEnum(value, allowed, where, errors) {
|
|
143
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
144
|
+
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function requireText(value, where, errors) {
|
|
148
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
149
|
+
errors.push(`${where} must be a non-empty string`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function optionalText(value, where, errors) {
|
|
153
|
+
if (value !== undefined)
|
|
154
|
+
requireText(value, where, errors);
|
|
155
|
+
}
|
|
156
|
+
function optionalBoolean(value, where, errors) {
|
|
157
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
158
|
+
errors.push(`${where} must be a boolean`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function requireId(value, pluginId, where, seen, errors) {
|
|
162
|
+
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
|
|
163
|
+
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (pluginId && !value.startsWith(`${pluginId}.`)) {
|
|
167
|
+
errors.push(`${where}.id must start with "${pluginId}."`);
|
|
168
|
+
}
|
|
169
|
+
if (seen.has(value))
|
|
170
|
+
errors.push(`${where}.id "${value}" is declared twice`);
|
|
171
|
+
seen.add(value);
|
|
172
|
+
}
|
|
173
|
+
function eachObject(value, where, errors, each) {
|
|
174
|
+
if (value === undefined)
|
|
175
|
+
return;
|
|
176
|
+
if (!Array.isArray(value)) {
|
|
177
|
+
errors.push(`${where} must be an array`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
value.forEach((raw, index) => {
|
|
181
|
+
const at = `${where}[${index}]`;
|
|
182
|
+
if (!isObject(raw)) {
|
|
183
|
+
errors.push(`${at} must be an object`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
each(raw, at);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function validateFields(value, where, errors) {
|
|
190
|
+
const keys = new Set();
|
|
191
|
+
eachObject(value, where, errors, (raw, at) => {
|
|
192
|
+
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
|
|
193
|
+
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
|
|
194
|
+
errors.push(`${at}.key must be a short name starting with a letter`);
|
|
195
|
+
}
|
|
196
|
+
else if (keys.has(raw.key)) {
|
|
197
|
+
errors.push(`${at}.key "${raw.key}" is declared twice`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
keys.add(raw.key);
|
|
201
|
+
}
|
|
202
|
+
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
|
|
203
|
+
optionalText(raw.labelKey, `${at}.labelKey`, errors);
|
|
204
|
+
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
205
|
+
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
|
|
206
|
+
optionalText(raw.group, `${at}.group`, errors);
|
|
207
|
+
optionalText(raw.requires, `${at}.requires`, errors);
|
|
208
|
+
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
|
|
209
|
+
optionalBoolean(raw.required, `${at}.required`, errors);
|
|
210
|
+
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
|
|
211
|
+
if (raw.type === "resource" && typeof raw.resource !== "string") {
|
|
212
|
+
errors.push(`${at}.resource is required for type "resource"`);
|
|
213
|
+
}
|
|
214
|
+
if (raw.resource !== undefined && raw.type !== "resource") {
|
|
215
|
+
errors.push(`${at}.resource needs type "resource"`);
|
|
216
|
+
}
|
|
217
|
+
if (raw.type === "secret" && raw.default !== undefined) {
|
|
218
|
+
errors.push(`${at}.default is not allowed on a secret`);
|
|
219
|
+
}
|
|
220
|
+
if ((raw.type === "select" || raw.type === "multiselect") &&
|
|
221
|
+
!raw.optionsFrom &&
|
|
222
|
+
(!Array.isArray(raw.options) || raw.options.length === 0)) {
|
|
223
|
+
errors.push(`${at} needs options or optionsFrom`);
|
|
224
|
+
}
|
|
225
|
+
for (const bound of ["min", "max"]) {
|
|
226
|
+
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
|
|
227
|
+
errors.push(`${at}.${bound} must be a number`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (raw.pattern !== undefined) {
|
|
231
|
+
if (typeof raw.pattern !== "string") {
|
|
232
|
+
errors.push(`${at}.pattern must be a string`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
try {
|
|
236
|
+
new RegExp(raw.pattern);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
errors.push(`${at}.pattern is not a valid regular expression`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function validateWhen(value, at, errors) {
|
|
246
|
+
if (value === undefined)
|
|
247
|
+
return;
|
|
248
|
+
if (!isObject(value)) {
|
|
249
|
+
errors.push(`${at} must be an object`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
rejectUnknown(value, ["settings", "os"], at, errors);
|
|
253
|
+
if (value.settings !== undefined && !isObject(value.settings)) {
|
|
254
|
+
errors.push(`${at}.settings must be an object`);
|
|
255
|
+
}
|
|
256
|
+
if (value.os !== undefined &&
|
|
257
|
+
(!Array.isArray(value.os) ||
|
|
258
|
+
value.os.some((entry) => typeof entry !== "string"))) {
|
|
259
|
+
errors.push(`${at}.os must be an array of strings`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function validateAction(raw, at, errors) {
|
|
263
|
+
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
|
|
264
|
+
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
265
|
+
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
266
|
+
optionalText(raw.icon, `${at}.icon`, errors);
|
|
267
|
+
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
|
|
268
|
+
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
|
|
269
|
+
requireText(raw.permission, `${at}.permission`, errors);
|
|
270
|
+
validateFields(raw.inputs, `${at}.inputs`, errors);
|
|
271
|
+
validateFields(raw.outputs, `${at}.outputs`, errors);
|
|
272
|
+
validateWhen(raw.when, `${at}.when`, errors);
|
|
273
|
+
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
|
|
274
|
+
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
|
|
275
|
+
if (raw.version !== undefined &&
|
|
276
|
+
(!Number.isInteger(raw.version) || raw.version < 1)) {
|
|
277
|
+
errors.push(`${at}.version must be a whole number from 1`);
|
|
278
|
+
}
|
|
279
|
+
if (raw.dryRun !== undefined) {
|
|
280
|
+
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
|
|
281
|
+
}
|
|
282
|
+
if (raw.timeoutMs !== undefined &&
|
|
283
|
+
(typeof raw.timeoutMs !== "number" ||
|
|
284
|
+
raw.timeoutMs <= 0 ||
|
|
285
|
+
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)) {
|
|
286
|
+
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
|
|
287
|
+
}
|
|
288
|
+
if (raw.scope === "resource" && typeof raw.resource !== "string") {
|
|
289
|
+
errors.push(`${at}.resource is required for scope "resource"`);
|
|
290
|
+
}
|
|
291
|
+
if (raw.scope !== "resource" && raw.resource !== undefined) {
|
|
292
|
+
errors.push(`${at}.resource needs scope "resource"`);
|
|
293
|
+
}
|
|
294
|
+
if (raw.scope === "server" && raw.hostAccess !== undefined) {
|
|
295
|
+
errors.push(`${at}.hostAccess does not apply to scope "server"`);
|
|
296
|
+
}
|
|
297
|
+
if (raw.scope === "host" || raw.scope === "resource") {
|
|
298
|
+
requireEnum(raw.hostAccess, OPERATION_HOST_ACCESS, `${at}.hostAccess`, errors);
|
|
299
|
+
if (raw.risk === "destructive" &&
|
|
300
|
+
(raw.hostAccess === "connect" || raw.hostAccess === "view")) {
|
|
301
|
+
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (raw.surfaces !== undefined) {
|
|
305
|
+
if (!isObject(raw.surfaces)) {
|
|
306
|
+
errors.push(`${at}.surfaces must be an object`);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
|
|
310
|
+
for (const [key, value] of Object.entries(raw.surfaces)) {
|
|
311
|
+
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function validateResource(raw, at, errors) {
|
|
317
|
+
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
|
|
318
|
+
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
319
|
+
requireText(raw.list, `${at}.list`, errors);
|
|
320
|
+
requireText(raw.key, `${at}.key`, errors);
|
|
321
|
+
requireText(raw.name, `${at}.name`, errors);
|
|
322
|
+
optionalText(raw.icon, `${at}.icon`, errors);
|
|
323
|
+
optionalText(raw.status, `${at}.status`, errors);
|
|
324
|
+
optionalText(raw.url, `${at}.url`, errors);
|
|
325
|
+
optionalText(raw.tab, `${at}.tab`, errors);
|
|
326
|
+
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
|
|
327
|
+
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
|
|
328
|
+
requireText(column.key, `${where}.key`, errors);
|
|
329
|
+
requireText(column.titleKey, `${where}.titleKey`, errors);
|
|
330
|
+
if (column.type !== undefined) {
|
|
331
|
+
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
if (raw.filters !== undefined &&
|
|
335
|
+
(!Array.isArray(raw.filters) ||
|
|
336
|
+
raw.filters.some((entry) => typeof entry !== "string"))) {
|
|
337
|
+
errors.push(`${at}.filters must be an array of strings`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Shape checks for contributes.operations, run by validateManifest. */
|
|
341
|
+
export function validateOperations(value, pluginId, errors) {
|
|
342
|
+
if (value === undefined)
|
|
343
|
+
return;
|
|
344
|
+
const root = "contributes.operations";
|
|
345
|
+
if (!isObject(value)) {
|
|
346
|
+
errors.push(`Field "${root}" must be an object`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
|
|
350
|
+
const ids = new Set();
|
|
351
|
+
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
|
|
352
|
+
requireId(raw.id, pluginId, at, ids, errors);
|
|
353
|
+
validateAction(raw, at, errors);
|
|
354
|
+
});
|
|
355
|
+
const eventIds = new Set();
|
|
356
|
+
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
|
|
357
|
+
requireId(raw.id, pluginId, at, eventIds, errors);
|
|
358
|
+
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
|
|
359
|
+
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
360
|
+
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
|
|
361
|
+
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
|
|
362
|
+
if (raw.severity !== undefined) {
|
|
363
|
+
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
|
|
364
|
+
}
|
|
365
|
+
validateFields(raw.payload, `${at}.payload`, errors);
|
|
366
|
+
});
|
|
367
|
+
const resourceIds = new Set();
|
|
368
|
+
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
|
|
369
|
+
requireId(raw.id, pluginId, at, resourceIds, errors);
|
|
370
|
+
validateResource(raw, at, errors);
|
|
371
|
+
});
|
|
372
|
+
const signalIds = new Set();
|
|
373
|
+
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
|
|
374
|
+
requireId(raw.id, pluginId, at, signalIds, errors);
|
|
375
|
+
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
|
|
376
|
+
requireText(raw.titleKey, `${at}.titleKey`, errors);
|
|
377
|
+
requireText(raw.read, `${at}.read`, errors);
|
|
378
|
+
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
|
|
379
|
+
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
|
|
380
|
+
optionalText(raw.fix, `${at}.fix`, errors);
|
|
381
|
+
if (raw.refreshSec !== undefined &&
|
|
382
|
+
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)) {
|
|
383
|
+
errors.push(`${at}.refreshSec must be at least 10`);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
if (value.invokes !== undefined) {
|
|
387
|
+
if (!Array.isArray(value.invokes) ||
|
|
388
|
+
value.invokes.some((entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry))) {
|
|
389
|
+
errors.push(`${root}.invokes must list action ids, "<pluginId>.*" or "*"`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The cross-field rules: every id one declaration names must exist in this
|
|
395
|
+
* plugin, or a surface would offer a button that goes nowhere.
|
|
396
|
+
*/
|
|
397
|
+
export function checkOperationReferences(operations, capabilities, hasPermission, errors) {
|
|
398
|
+
if (!operations)
|
|
399
|
+
return;
|
|
400
|
+
const root = "contributes.operations";
|
|
401
|
+
const actions = new Map((operations.actions ?? []).map((action) => [action.id, action]));
|
|
402
|
+
const resources = new Set((operations.resources ?? []).map((r) => r.id));
|
|
403
|
+
const declaresAnything = actions.size > 0 ||
|
|
404
|
+
(operations.events?.length ?? 0) > 0 ||
|
|
405
|
+
resources.size > 0 ||
|
|
406
|
+
(operations.signals?.length ?? 0) > 0;
|
|
407
|
+
if (declaresAnything && !capabilities.includes("operations:provide")) {
|
|
408
|
+
errors.push(`${root} needs the "operations:provide" capability`);
|
|
409
|
+
}
|
|
410
|
+
if (operations.invokes?.length &&
|
|
411
|
+
!capabilities.includes("operations:invoke")) {
|
|
412
|
+
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
|
|
413
|
+
}
|
|
414
|
+
const readAction = (id, where) => {
|
|
415
|
+
const target = actions.get(id);
|
|
416
|
+
if (!target)
|
|
417
|
+
errors.push(`${where} "${id}" is not a declared action`);
|
|
418
|
+
else if (target.risk !== "read") {
|
|
419
|
+
errors.push(`${where} "${id}" must be a read action`);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
for (const action of actions.values()) {
|
|
423
|
+
const at = `${root}.actions["${action.id}"]`;
|
|
424
|
+
if (!hasPermission(action.permission)) {
|
|
425
|
+
errors.push(`${at}.permission "${action.permission}" is not declared in contributes.permissions`);
|
|
426
|
+
}
|
|
427
|
+
if (action.resource && !resources.has(action.resource)) {
|
|
428
|
+
errors.push(`${at}.resource "${action.resource}" is not declared`);
|
|
429
|
+
}
|
|
430
|
+
if (action.check)
|
|
431
|
+
readAction(action.check, `${at}.check`);
|
|
432
|
+
if (action.undo && !actions.has(action.undo)) {
|
|
433
|
+
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
|
|
434
|
+
}
|
|
435
|
+
for (const field of action.inputs ?? []) {
|
|
436
|
+
if (field.optionsFrom) {
|
|
437
|
+
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
|
|
438
|
+
}
|
|
439
|
+
if (field.resource && !resources.has(field.resource)) {
|
|
440
|
+
errors.push(`${at} input "${field.key}" names an unknown resource`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const resource of operations.resources ?? []) {
|
|
445
|
+
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
|
|
446
|
+
}
|
|
447
|
+
for (const signal of operations.signals ?? []) {
|
|
448
|
+
const at = `${root}.signals["${signal.id}"]`;
|
|
449
|
+
readAction(signal.read, `${at}.read`);
|
|
450
|
+
if (signal.fix && !actions.has(signal.fix)) {
|
|
451
|
+
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** Whether a plugin's invokes list lets it run an action. */
|
|
456
|
+
export function invokeAllowed(invokes, actionId) {
|
|
457
|
+
for (const entry of invokes ?? []) {
|
|
458
|
+
if (entry === "*" || entry === actionId)
|
|
459
|
+
return true;
|
|
460
|
+
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
const RISK_RANK = {
|
|
467
|
+
read: 0,
|
|
468
|
+
change: 1,
|
|
469
|
+
destructive: 2,
|
|
470
|
+
};
|
|
471
|
+
export function riskRank(risk) {
|
|
472
|
+
return RISK_RANK[risk];
|
|
473
|
+
}
|
|
474
|
+
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
|
|
475
|
+
export function onSurface(action, surface) {
|
|
476
|
+
const value = action.surfaces?.[surface];
|
|
477
|
+
if (surface === "assistant" || surface === "mcp")
|
|
478
|
+
return value === true;
|
|
479
|
+
return value !== false;
|
|
480
|
+
}
|
|
481
|
+
/** Whether an action applies to a host, from fields any client can read. */
|
|
482
|
+
export function matchesWhen(when, host) {
|
|
483
|
+
if (!when)
|
|
484
|
+
return true;
|
|
485
|
+
for (const [key, expected] of Object.entries(when.settings ?? {})) {
|
|
486
|
+
if (host.settings?.[key] !== expected)
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
if (when.os?.length && host.os) {
|
|
490
|
+
if (!when.os.includes(host.os.toLowerCase()))
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
function asSettingsField(field) {
|
|
496
|
+
const type = field.type === "resource" ? "string" : field.type;
|
|
497
|
+
return { ...field, type };
|
|
498
|
+
}
|
|
499
|
+
function isEmpty(value) {
|
|
500
|
+
return value === undefined || value === null || value === "";
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Turns raw input into validated values. Keys no field declares are refused,
|
|
504
|
+
* so nothing a handler did not ask for ever reaches it.
|
|
505
|
+
*/
|
|
506
|
+
export function parseOperationInput(fields, raw) {
|
|
507
|
+
const errors = [];
|
|
508
|
+
const values = {};
|
|
509
|
+
const list = fields ?? [];
|
|
510
|
+
if (raw !== undefined && raw !== null && !isObject(raw)) {
|
|
511
|
+
return { values, errors: ["Input must be an object"] };
|
|
512
|
+
}
|
|
513
|
+
const input = (raw ?? {});
|
|
514
|
+
for (const key of Object.keys(input)) {
|
|
515
|
+
if (!list.some((field) => field.key === key)) {
|
|
516
|
+
errors.push(`"${key}" is not an input of this action`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const field of list) {
|
|
520
|
+
if (field.requires && input[field.requires] !== true)
|
|
521
|
+
continue;
|
|
522
|
+
let value = input[field.key];
|
|
523
|
+
if (isEmpty(value)) {
|
|
524
|
+
if (field.default !== undefined) {
|
|
525
|
+
values[field.key] = field.default;
|
|
526
|
+
}
|
|
527
|
+
else if (field.required) {
|
|
528
|
+
errors.push(`"${field.key}" is required`);
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const settingsField = asSettingsField(field);
|
|
533
|
+
value = coerceSettingValue(settingsField, value);
|
|
534
|
+
if (field.type === "secret" && typeof value !== "string") {
|
|
535
|
+
errors.push(`"${field.key}" must be a string`);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (field.type === "json" && typeof value === "string") {
|
|
539
|
+
try {
|
|
540
|
+
value = JSON.parse(value);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
errors.push(`"${field.key}" must be valid JSON`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// A list known only at run time is checked by the handler.
|
|
548
|
+
const skipChoices = field.optionsFrom &&
|
|
549
|
+
(field.type === "select" || field.type === "multiselect");
|
|
550
|
+
const problem = skipChoices
|
|
551
|
+
? field.type === "select"
|
|
552
|
+
? typeof value === "string"
|
|
553
|
+
? null
|
|
554
|
+
: `"${field.key}" must be a string`
|
|
555
|
+
: Array.isArray(value)
|
|
556
|
+
? null
|
|
557
|
+
: `"${field.key}" must be an array`
|
|
558
|
+
: validateSettingValue(settingsField, value);
|
|
559
|
+
if (problem) {
|
|
560
|
+
errors.push(problem);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (field.pattern &&
|
|
564
|
+
typeof value === "string" &&
|
|
565
|
+
!new RegExp(`^(?:${field.pattern})$`).test(value)) {
|
|
566
|
+
errors.push(`"${field.key}" has the wrong format`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
values[field.key] = value;
|
|
570
|
+
}
|
|
571
|
+
return { values, errors };
|
|
572
|
+
}
|
|
573
|
+
/** A copy safe to store or show: secret and sensitive values are replaced. */
|
|
574
|
+
export function redactFields(fields, values) {
|
|
575
|
+
if (!isObject(values))
|
|
576
|
+
return values;
|
|
577
|
+
const copy = { ...values };
|
|
578
|
+
for (const field of fields ?? []) {
|
|
579
|
+
if (field.type !== "secret" && !field.sensitive)
|
|
580
|
+
continue;
|
|
581
|
+
if (field.key in copy)
|
|
582
|
+
copy[field.key] = REDACTED_VALUE;
|
|
583
|
+
}
|
|
584
|
+
return copy;
|
|
585
|
+
}
|
|
586
|
+
/** Trims text to a byte-friendly length with a plain marker. */
|
|
587
|
+
export function capText(text, maxLength) {
|
|
588
|
+
return text.length <= maxLength
|
|
589
|
+
? text
|
|
590
|
+
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
|
|
591
|
+
}
|
|
592
|
+
function fieldSchema(field) {
|
|
593
|
+
const schema = {};
|
|
594
|
+
if (field.default !== undefined)
|
|
595
|
+
schema.default = field.default;
|
|
596
|
+
const values = field.optionsFrom
|
|
597
|
+
? undefined
|
|
598
|
+
: field.options?.map((option) => option.value);
|
|
599
|
+
switch (field.type) {
|
|
600
|
+
case "boolean":
|
|
601
|
+
schema.type = "boolean";
|
|
602
|
+
break;
|
|
603
|
+
case "number":
|
|
604
|
+
schema.type = "number";
|
|
605
|
+
if (field.min !== undefined)
|
|
606
|
+
schema.minimum = field.min;
|
|
607
|
+
if (field.max !== undefined)
|
|
608
|
+
schema.maximum = field.max;
|
|
609
|
+
break;
|
|
610
|
+
case "select":
|
|
611
|
+
schema.type = "string";
|
|
612
|
+
if (values)
|
|
613
|
+
schema.enum = values;
|
|
614
|
+
break;
|
|
615
|
+
case "multiselect":
|
|
616
|
+
schema.type = "array";
|
|
617
|
+
schema.items = values
|
|
618
|
+
? { type: "string", enum: values }
|
|
619
|
+
: { type: "string" };
|
|
620
|
+
break;
|
|
621
|
+
case "json":
|
|
622
|
+
break;
|
|
623
|
+
default:
|
|
624
|
+
schema.type = "string";
|
|
625
|
+
if (field.pattern)
|
|
626
|
+
schema.pattern = `^(?:${field.pattern})$`;
|
|
627
|
+
}
|
|
628
|
+
if (field.type === "resource" && field.resource) {
|
|
629
|
+
schema["x-termix-resource"] = field.resource;
|
|
630
|
+
}
|
|
631
|
+
if (field.type === "secret")
|
|
632
|
+
schema.writeOnly = true;
|
|
633
|
+
return schema;
|
|
634
|
+
}
|
|
635
|
+
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
|
|
636
|
+
export function toJsonSchema(fields, describe) {
|
|
637
|
+
const properties = {};
|
|
638
|
+
const required = [];
|
|
639
|
+
for (const field of fields ?? []) {
|
|
640
|
+
const schema = fieldSchema(field);
|
|
641
|
+
const description = describe?.(field);
|
|
642
|
+
if (description)
|
|
643
|
+
schema.description = description;
|
|
644
|
+
properties[field.key] = schema;
|
|
645
|
+
if (field.required && field.default === undefined && !field.requires) {
|
|
646
|
+
required.push(field.key);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
650
|
+
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface FakeWsRoute {
|
|
|
23
23
|
options?: PluginWebSocketOptions;
|
|
24
24
|
}
|
|
25
25
|
import type { PluginLoginMethod, PluginVerifiedIdentity, PluginSecondFactor, PluginSshAuthProvider, PluginSshConnectOptions, PluginSshHost, PluginHostSummary, PluginShareableUser, PluginShareableRole, PluginSshKeyCredential, PluginSshKeyCredentialInput } from "./backend.js";
|
|
26
|
-
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
|
26
|
+
import type { PluginOperationHandler, PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
|
27
27
|
import type { PluginDatabase } from "./backend.js";
|
|
28
28
|
import type { PluginExternalClientRequest, PluginProtocolTarget, PluginHostProbeTarget, PluginHostStatusEntry, PluginActivityProvider, PluginHostLockProvider, PluginAuthPolicyProvider, PluginCredentialSummary, PluginHostShareGrant, PluginHostStatusProvider, PluginNotification, PluginNotificationChannel, PluginNotifyHub, PluginFetchInit, PluginBinarySpec, PluginTlsCertificateInfo, PluginTlsStatus, PluginSummary, PluginKeyboardInteractiveHandler, PluginProcessHandle, PluginProcessOptions } from "./backend.js";
|
|
29
29
|
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
|
|
@@ -262,6 +262,14 @@ export interface FakePluginContext {
|
|
|
262
262
|
notifyHub: {
|
|
263
263
|
current: PluginNotifyHub | null;
|
|
264
264
|
};
|
|
265
|
+
/** Handlers from ctx.operations.register, by action id. */
|
|
266
|
+
operationHandlers: Map<string, PluginOperationHandler>;
|
|
267
|
+
/** Every ctx.operations.emit call. */
|
|
268
|
+
operationEvents: {
|
|
269
|
+
eventId: string;
|
|
270
|
+
payload: unknown;
|
|
271
|
+
hostId?: number;
|
|
272
|
+
}[];
|
|
265
273
|
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
|
|
266
274
|
secretStore: Map<string, string>;
|
|
267
275
|
/** Every ctx.fetch call, in order. */
|