mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
feat: add the operations layer for declared plugin actions
This commit is contained in:
1 parent
b87fe3c6a5
commit
cc725c7889
54 files changed
+22319
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user