feat: add the operations layer for declared plugin actions

This commit is contained in:
LukeGus committed 2026-11-03 22:28:54 -06:00
1 parent b87fe3c6a5
commit cc725c7889
54 files changed
+22319 -8

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 1.0.23
Plugin API 1.14.0.
### Added
- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
- `contributes.hostSections` declares cards for the host page
## 1.0.22
Plugin API 1.13.0.
+69 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.22 | Plugin API 1.13.0
SDK 1.0.23 | Plugin API 1.14.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that.
### Operations
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
dry runs, run history, approvals and drift checks.
```json
"capabilities": ["operations:provide"],
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
"contributes": {
"operations": {
"actions": [{
"id": "demo.restart",
"titleKey": "ops.restart.title",
"scope": "host",
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
"risk": "change",
"permission": "use",
"hostAccess": "edit",
"dryRun": "describe"
}]
}
}
```
```ts
ctx.operations.register("demo.restart", {
describe: (input) => `Would restart ${input.name}`,
run: async (input, { hostId, signal, log }) => {
return { summary: `Restarted ${input.name}`, changed: true };
},
});
```
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
must use `edit` or `manage` and needs a confirm.
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
but are never stored or shown. Mark private outputs `sensitive`.
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
hosts by setting or OS.
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
ask for. The rest are on unless you set them false.
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
- Handlers have no request and no session. Take what you need from the input and `hostId`.
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
the same checks and lands in the same history.
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
allows them.
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
`/ui`, or the phone app will ask people to update to see your plugin.
### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -962,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
@@ -993,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
| `operations:approve` | high | Decide which actions need a person |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots |
@@ -1008,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `operations:provide` | medium | Offer declared actions and events |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.22",
"version": "1.0.23",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -61,6 +61,10 @@
"types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js"
},
"./operations": {
"types": "./dist/operations.d.ts",
"default": "./dist/operations.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
@@ -536,6 +536,31 @@
}
}
},
"hostSections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"operations": {
"type": "object",
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "type": "object" } },
"events": { "type": "array", "items": { "type": "object" } },
"resources": { "type": "array", "items": { "type": "object" } },
"signals": { "type": "array", "items": { "type": "object" } },
"invokes": { "type": "array", "items": { "type": "string" } }
}
},
"dashboardCards": {
"type": "array",
"items": {
+137
View File
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type {
OperationResult,
OperationRisk,
OperationSurfaces,
} from "./operations.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
@@ -3104,6 +3109,136 @@ export interface PluginPlugins {
list: () => Promise<PluginSummary[]>;
}
/** What a handler gets besides its input. */
export interface PluginOperationContext {
hostId?: number;
/** The row a resource action targets. */
resourceKey?: string;
/** Null for a background read. */
runId: string | null;
/** Nothing may change when this is set. */
dryRun: boolean;
/** Fires when the run is cancelled, times out or the plugin stops. */
signal: AbortSignal;
log: (line: string) => void;
}
export interface PluginOperationHandler {
run: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => Promise<OperationResult> | OperationResult;
/** The dry run text for an action declared with dryRun "describe". */
describe?: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => string | Promise<string>;
}
export interface PluginOperationInvokeOptions {
hostId?: number;
resourceKey?: string;
input?: Record<string, unknown>;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
export interface PluginOperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
export interface PluginOperationApprovalInput {
actionId: string;
pluginId: string;
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: { kind: string; pluginId?: string; via?: string };
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface PluginOperationApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
allowSelf?: boolean;
expiresInMinutes?: number;
}
export interface PluginOperationEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
/**
* Declared actions and events. See contributes.operations in the manifest.
* register and emit need operations:provide. invoke, list and onEvent need
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
*/
export interface PluginOperations {
register: (id: string, handler: PluginOperationHandler) => void;
/**
* Runs one of this plugin's own actions from its route, so a button press
* goes through the same checks and lands in the same history.
*/
run: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
invoke: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
list: (options?: {
lang?: string;
surface?: keyof OperationSurfaces;
}) => Promise<unknown>;
emit: (
eventId: string,
payload?: unknown,
options?: { hostId?: number },
) => Promise<void>;
onEvent: (subscription: {
pattern: string;
handler: (event: PluginOperationEvent) => void | Promise<void>;
userId?: string;
cooldownMs?: number;
}) => () => void;
/** Rules for what needs a person. Needs operations:approve. */
approvals: {
provide: (
provider: (
input: PluginOperationApprovalInput,
) =>
| PluginOperationApprovalDecision
| null
| undefined
| Promise<PluginOperationApprovalDecision | null | undefined>,
) => () => void;
};
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -3144,6 +3279,8 @@ export interface PluginContext {
readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify;
/** Declared actions and events, reachable from every surface. */
readonly operations: PluginOperations;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/** Plain reachability checks. Needs network:probe. */
+3
View File
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:transport", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("operations:invoke", "high"),
entry("operations:approve", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("operations:provide", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("desktop:local", "high"),
+20 -1
View File
@@ -12,6 +12,11 @@
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
import {
checkOperationReferences,
validateOperations,
type PluginOperationsContribution,
} from "./operations.js";
import {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -37,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.13.0";
export const PLUGIN_API_VERSION = "1.14.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -465,6 +470,10 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[];
/** Cards on the host page, filled with app.registerHostSection. */
hostSections?: PluginViewContribution[];
/** What this plugin can do, for every surface to find on its own. */
operations?: PluginOperationsContribution;
/**
* The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set.
@@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"panels",
"dashboardCards",
"hostSections",
"operations",
"guest",
"guestViews",
"actions",
@@ -1500,6 +1511,8 @@ function validateContributes(
validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors);
validateViews(contributes.hostSections, "hostSections", errors);
validateOperations(contributes.operations, pluginId, errors);
if (
contributes.guest !== undefined &&
typeof contributes.guest !== "boolean"
@@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
}
checkOperationReferences(
manifest.contributes?.operations,
manifest.capabilities,
(name) => declared.has(qualifyPermission(manifest.id, name)),
errors,
);
// A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin.
+951
View File
@@ -0,0 +1,951 @@
/**
* 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 };
}
+53 -1
View File
@@ -68,7 +68,11 @@ import type {
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type {
PluginOperationHandler,
PluginSyncHost,
SyncEntityRegistration,
} from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
@@ -355,6 +359,10 @@ export interface FakePluginContext {
}>;
/** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null };
/** Handlers from ctx.operations.register, by action id. */
operationHandlers: Map<string, PluginOperationHandler>;
/** Every ctx.operations.emit call. */
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
@@ -566,6 +574,8 @@ export function createFakeContext(
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
const operationEvents: FakePluginContext["operationEvents"] = [];
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
@@ -1391,6 +1401,46 @@ export function createFakeContext(
},
},
operations: {
register: (id, handler) => {
operationHandlers.set(id, handler);
},
run: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
invoke: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
list: async () => ({
actions: manifest.contributes?.operations?.actions ?? [],
}),
emit: async (eventId, payload, options) => {
operationEvents.push({ eventId, payload, hostId: options?.hostId });
},
onEvent: () => () => {},
approvals: { provide: () => () => {} },
},
notify: {
channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user");
@@ -1554,6 +1604,8 @@ export function createFakeContext(
createdSshKeys,
notifications,
notifyHub,
operationHandlers,
operationEvents,
fetches,
processRuns,
binaries,