mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45: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
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Who may run what without a person saying yes.
|
||||
*
|
||||
* A plugin fills this through ctx.operations.approvals.provide. Core asks every
|
||||
* provider and the strictest answer wins. A provider that throws denies the
|
||||
* run, since skipping a safety rule is worse than a refused action.
|
||||
*/
|
||||
|
||||
import type { OperationRisk } from "@termix-ssh/plugin-sdk/operations";
|
||||
import { riskRank } from "@termix-ssh/plugin-sdk/operations";
|
||||
import { hasCapability } from "../plugins/permissions.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { ActorOrigin } from "../plugins/actor.js";
|
||||
import type { PluginManifest } from "../plugins/manifest.js";
|
||||
|
||||
export interface ApprovalPolicyInput {
|
||||
actionId: string;
|
||||
pluginId: string;
|
||||
/** What the plugin declared. */
|
||||
risk: OperationRisk;
|
||||
/** What rules should use. Plugins not signed as official count as change. */
|
||||
effectiveRisk: OperationRisk;
|
||||
origin: ActorOrigin;
|
||||
userId: string;
|
||||
hostId?: number;
|
||||
hostTags?: string[];
|
||||
batchId?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalDecision {
|
||||
decision: "allow" | "approve" | "deny";
|
||||
reason?: string;
|
||||
/** The requester may approve their own run. */
|
||||
allowSelf?: boolean;
|
||||
expiresInMinutes?: number;
|
||||
}
|
||||
|
||||
export type ApprovalPolicyProvider = (
|
||||
input: ApprovalPolicyInput,
|
||||
) =>
|
||||
| ApprovalDecision
|
||||
| null
|
||||
| undefined
|
||||
| Promise<ApprovalDecision | null | undefined>;
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
manifest: PluginManifest;
|
||||
provider: ApprovalPolicyProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideApprovalPolicy(
|
||||
pluginId: string,
|
||||
manifest: PluginManifest,
|
||||
provider: ApprovalPolicyProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, manifest, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearApprovalPolicies(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
|
||||
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
||||
|
||||
/** A person is the check when the request came from something else. */
|
||||
export function canSelfApprove(origin: ActorOrigin): boolean {
|
||||
return origin.kind === "plugin" && SELF_APPROVE_VIA.has(origin.via ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when no provider has an opinion. People and their own keys go by RBAC,
|
||||
* and so do automations they set up. Anything else needs a rule that allows it.
|
||||
*/
|
||||
function builtIn(input: ApprovalPolicyInput): ApprovalDecision {
|
||||
if (riskRank(input.effectiveRisk) === 0) return { decision: "allow" };
|
||||
if (input.origin.kind !== "plugin") return { decision: "allow" };
|
||||
if (TRUSTED_VIA.has(input.origin.via ?? "")) return { decision: "allow" };
|
||||
return {
|
||||
decision: "deny",
|
||||
reason: "This needs an approval rule. Turn on approvals to allow it.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function evaluateApproval(
|
||||
input: ApprovalPolicyInput,
|
||||
): Promise<ApprovalDecision> {
|
||||
let strictest: ApprovalDecision | null = null;
|
||||
for (const entry of providers.values()) {
|
||||
let decision: ApprovalDecision | null | undefined;
|
||||
try {
|
||||
// Rechecked at use, so a revoked grant stops the provider now.
|
||||
if (
|
||||
!(await hasCapability(
|
||||
entry.pluginId,
|
||||
"operations:approve",
|
||||
entry.manifest.capabilities,
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
decision = await entry.provider(input);
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Approval rule failed, refusing the run", {
|
||||
operation: "operation_approval",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
decision: "deny",
|
||||
reason: "Approval rules are not available right now.",
|
||||
};
|
||||
}
|
||||
if (!decision) continue;
|
||||
if (!strictest || RANK[decision.decision] > RANK[strictest.decision]) {
|
||||
strictest = decision;
|
||||
}
|
||||
}
|
||||
return strictest ?? builtIn(input);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* What a user can do right now, in their language.
|
||||
*
|
||||
* Built from the manifests of running plugins, never from GET /plugins, which
|
||||
* hands every signed-in user the raw manifest. A plugin's locale file loads
|
||||
* here on the server, because its frontend may not be loaded on the client:
|
||||
* the CLI has no frontend, and a linked desktop may not have the plugin at all.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
onSurface,
|
||||
toJsonSchema,
|
||||
type OperationAction,
|
||||
type OperationEvent,
|
||||
type OperationField,
|
||||
type OperationResource,
|
||||
type OperationSignal,
|
||||
type OperationSurfaces,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
import { getAction } from "./registry.js";
|
||||
import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
|
||||
interface CatalogPlugin {
|
||||
id: string;
|
||||
dir: string;
|
||||
manifest: PluginManifest;
|
||||
state: string;
|
||||
official: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogField extends OperationField {
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
optionLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CatalogAction extends Omit<
|
||||
OperationAction,
|
||||
"inputs" | "outputs"
|
||||
> {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
official: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
inputs: CatalogField[];
|
||||
outputs: CatalogField[];
|
||||
inputSchema: Record<string, unknown>;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogEvent extends OperationEvent {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CatalogResource extends OperationResource {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
columns: (NonNullable<OperationResource["columns"]>[number] & {
|
||||
title: string;
|
||||
})[];
|
||||
}
|
||||
|
||||
export interface CatalogSignal extends OperationSignal {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface OperationCatalog {
|
||||
actions: CatalogAction[];
|
||||
events: CatalogEvent[];
|
||||
resources: CatalogResource[];
|
||||
signals: CatalogSignal[];
|
||||
}
|
||||
|
||||
type Strings = Record<string, string>;
|
||||
|
||||
const localeCache = new Map<string, Strings>();
|
||||
|
||||
function flatten(value: unknown, prefix: string, out: Strings): void {
|
||||
if (typeof value === "string") {
|
||||
out[prefix] = value;
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
|
||||
function readLocale(file: string): Strings {
|
||||
const out: Strings = {};
|
||||
try {
|
||||
flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out);
|
||||
} catch {
|
||||
// A missing or broken file falls back to the key.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Locale names reach a path, so only a plain language tag is used. */
|
||||
function safeLanguage(lang: string | undefined): string {
|
||||
return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en";
|
||||
}
|
||||
|
||||
function stringsFor(plugin: CatalogPlugin, lang: string): Strings {
|
||||
const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`;
|
||||
const cached = localeCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales");
|
||||
const strings = readLocale(path.join(dir, "en.json"));
|
||||
if (lang !== "en") {
|
||||
Object.assign(
|
||||
strings,
|
||||
readLocale(path.join(dir, "translated", `${lang}.json`)),
|
||||
readLocale(path.join(dir, `${lang}.json`)),
|
||||
);
|
||||
}
|
||||
localeCache.set(cacheKey, strings);
|
||||
return strings;
|
||||
}
|
||||
|
||||
function localizedField(field: OperationField, strings: Strings): CatalogField {
|
||||
const text = (key?: string) => (key ? (strings[key] ?? key) : undefined);
|
||||
const optionLabels: Record<string, string> = {};
|
||||
for (const option of field.options ?? []) {
|
||||
optionLabels[option.value] = text(option.labelKey) ?? option.value;
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
label: text(field.labelKey) ?? field.key,
|
||||
description: text(field.descriptionKey),
|
||||
placeholder: text(field.placeholderKey),
|
||||
optionLabels: field.options ? optionLabels : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function runningPlugins(): Promise<CatalogPlugin[]> {
|
||||
const { getPluginRuntime } = await import("../plugins/index.js");
|
||||
return getPluginRuntime()
|
||||
.loader.list()
|
||||
.filter((plugin) => plugin.state === "active")
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
dir: plugin.dir,
|
||||
manifest: plugin.manifest,
|
||||
state: plugin.state,
|
||||
official: plugin.source === "bundled" || plugin.signedTier === "official",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function buildCatalog(options: {
|
||||
userId: string;
|
||||
lang?: string;
|
||||
surface?: keyof OperationSurfaces;
|
||||
}): Promise<OperationCatalog> {
|
||||
const lang = safeLanguage(options.lang);
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const catalog: OperationCatalog = {
|
||||
actions: [],
|
||||
events: [],
|
||||
resources: [],
|
||||
signals: [],
|
||||
};
|
||||
|
||||
for (const plugin of await runningPlugins()) {
|
||||
const operations = plugin.manifest.contributes?.operations;
|
||||
if (!operations) continue;
|
||||
const strings = stringsFor(plugin, lang);
|
||||
const text = (key: string) => strings[key] ?? key;
|
||||
|
||||
for (const action of operations.actions ?? []) {
|
||||
if (options.surface && !onSurface(action, options.surface)) continue;
|
||||
// Community plugins stay away from the assistant and MCP until an admin allows them.
|
||||
if (
|
||||
!plugin.official &&
|
||||
(options.surface === "assistant" || options.surface === "mcp")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const permission = qualifyPermission(plugin.id, action.permission);
|
||||
if (!(await permissions.hasPermission(options.userId, permission))) {
|
||||
continue;
|
||||
}
|
||||
const inputs = (action.inputs ?? []).map((field) =>
|
||||
localizedField(field, strings),
|
||||
);
|
||||
catalog.actions.push({
|
||||
...action,
|
||||
pluginId: plugin.id,
|
||||
pluginName: plugin.manifest.name,
|
||||
official: plugin.official,
|
||||
title: text(action.titleKey),
|
||||
description: action.descriptionKey
|
||||
? text(action.descriptionKey)
|
||||
: undefined,
|
||||
inputs,
|
||||
outputs: (action.outputs ?? [])
|
||||
.filter((field) => !field.sensitive)
|
||||
.map((field) => localizedField(field, strings)),
|
||||
inputSchema: toJsonSchema(
|
||||
action.inputs,
|
||||
(field) =>
|
||||
inputs.find((input) => input.key === field.key)?.description,
|
||||
),
|
||||
available: getAction(action.id) !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visible = new Set(catalog.actions.map((action) => action.id));
|
||||
for (const event of operations.events ?? []) {
|
||||
catalog.events.push({
|
||||
...event,
|
||||
pluginId: plugin.id,
|
||||
title: text(event.titleKey),
|
||||
});
|
||||
}
|
||||
for (const resource of operations.resources ?? []) {
|
||||
if (!visible.has(resource.list)) continue;
|
||||
catalog.resources.push({
|
||||
...resource,
|
||||
pluginId: plugin.id,
|
||||
title: text(resource.titleKey),
|
||||
columns: (resource.columns ?? []).map((column) => ({
|
||||
...column,
|
||||
title: text(column.titleKey),
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const signal of operations.signals ?? []) {
|
||||
if (!visible.has(signal.read)) continue;
|
||||
catalog.signals.push({
|
||||
...signal,
|
||||
pluginId: plugin.id,
|
||||
title: text(signal.titleKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearCatalogCache(): void {
|
||||
localeCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** How many events or actions may lead to one another before the chain stops. */
|
||||
export const MAX_EVENT_DEPTH = 3;
|
||||
|
||||
/** How long finished runs and events stay in history. */
|
||||
export const RETENTION_DAYS = 90;
|
||||
|
||||
/** How long a cached table row or tile counts as fresh. */
|
||||
export const RESOURCE_FRESH_MS = 60_000;
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Declared events, separate from the plugin.* bus any plugin can read.
|
||||
*
|
||||
* Each event is kept in a capped timeline. A subscriber bound to a user only
|
||||
* hears about hosts that user can reach, checked at delivery. Events carry a
|
||||
* depth, so a rule that fires an action that fires the same rule stops.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { MAX_EVENT_DEPTH } from "./constants.js";
|
||||
import { getActorTrail, runAsActor } from "../plugins/actor.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface OperationEventInput {
|
||||
eventId: string;
|
||||
pluginId: string;
|
||||
hostId?: number;
|
||||
severity?: string;
|
||||
payload?: unknown;
|
||||
runId?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface DeliveredEvent {
|
||||
id: string;
|
||||
eventId: string;
|
||||
pluginId: string;
|
||||
hostId?: number;
|
||||
severity?: string;
|
||||
payload?: unknown;
|
||||
runId?: string;
|
||||
depth: number;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface OperationEventSubscription {
|
||||
/** Exact id, `<pluginId>.*` or `*`. */
|
||||
pattern: string;
|
||||
handler: (event: DeliveredEvent) => void | Promise<void>;
|
||||
/** Only events for hosts this user can reach, and runs the handler as them. */
|
||||
userId?: string;
|
||||
/** Skips repeats of one event and host inside this window. */
|
||||
cooldownMs?: number;
|
||||
}
|
||||
|
||||
interface Subscriber extends OperationEventSubscription {
|
||||
pluginId: string;
|
||||
lastFired: Map<string, number>;
|
||||
}
|
||||
|
||||
const subscribers = new Set<Subscriber>();
|
||||
|
||||
function matches(pattern: string, eventId: string): boolean {
|
||||
if (pattern === "*" || pattern === eventId) return true;
|
||||
return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
|
||||
export function subscribeOperationEvents(
|
||||
pluginId: string,
|
||||
subscription: OperationEventSubscription,
|
||||
): () => void {
|
||||
const entry = { ...subscription, pluginId, lastFired: new Map() };
|
||||
subscribers.add(entry);
|
||||
return () => {
|
||||
subscribers.delete(entry);
|
||||
};
|
||||
}
|
||||
|
||||
async function deliver(subscriber: Subscriber, event: DeliveredEvent) {
|
||||
const key = `${event.eventId}|${event.hostId ?? ""}`;
|
||||
if (subscriber.cooldownMs) {
|
||||
const last = subscriber.lastFired.get(key) ?? 0;
|
||||
if (Date.now() - last < subscriber.cooldownMs) return;
|
||||
subscriber.lastFired.set(key, Date.now());
|
||||
}
|
||||
|
||||
const { userId } = subscriber;
|
||||
if (userId && event.hostId !== undefined) {
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
event.hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return;
|
||||
}
|
||||
|
||||
const fire = () => Promise.resolve(subscriber.handler(event));
|
||||
await (userId
|
||||
? runAsActor(userId, "service", fire, undefined, { depth: event.depth })
|
||||
: fire());
|
||||
}
|
||||
|
||||
/** Never throws. An event must not be able to fail what caused it. */
|
||||
export async function emitOperationEvent(
|
||||
input: OperationEventInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const trail = getActorTrail();
|
||||
const depth = input.depth ?? trail.depth ?? 0;
|
||||
const event: DeliveredEvent = {
|
||||
id: randomUUID(),
|
||||
eventId: input.eventId,
|
||||
pluginId: input.pluginId,
|
||||
hostId: input.hostId,
|
||||
severity: input.severity,
|
||||
payload: input.payload,
|
||||
runId: input.runId ?? trail.runId,
|
||||
depth,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await createCurrentOperationRepository().insertEvent({
|
||||
id: event.id,
|
||||
eventId: event.eventId,
|
||||
pluginId: event.pluginId,
|
||||
hostId: event.hostId ?? null,
|
||||
severity: event.severity ?? null,
|
||||
payload:
|
||||
event.payload === undefined ? null : JSON.stringify(event.payload),
|
||||
runId: event.runId ?? null,
|
||||
createdAt: event.at,
|
||||
});
|
||||
|
||||
if (depth >= MAX_EVENT_DEPTH) {
|
||||
pluginLogger.warn("Event chain stopped at the depth limit", {
|
||||
operation: "operation_event_depth",
|
||||
eventId: event.eventId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const subscriber of subscribers) {
|
||||
if (!matches(subscriber.pattern, event.eventId)) continue;
|
||||
void deliver(subscriber, event).catch((error) => {
|
||||
pluginLogger.warn("Event subscriber failed", {
|
||||
operation: "operation_event_delivery",
|
||||
pluginId: subscriber.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Could not record an event", {
|
||||
operation: "operation_event",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearOperationEvents(): void {
|
||||
subscribers.clear();
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
/**
|
||||
* The one path every action takes, from any surface.
|
||||
*
|
||||
* In order: plugin up, grant still held, input valid, role permission, host
|
||||
* access, key scope, destructive confirm, approval, limits, run row, audit.
|
||||
* Handlers run in-process, so this is an access check for callers, not a
|
||||
* sandbox around plugins.
|
||||
*
|
||||
* Handler failures stay here. They never reach the plugin error budget, or five
|
||||
* failed hosts in a rollout would switch the plugin off.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
capText,
|
||||
matchesWhen,
|
||||
parseOperationInput,
|
||||
redactFields,
|
||||
riskRank,
|
||||
type OperationAction,
|
||||
type OperationRisk,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
import { MAX_EVENT_DEPTH } from "./constants.js";
|
||||
import { getAction, type OperationRegistration } from "./registry.js";
|
||||
import {
|
||||
canSelfApprove,
|
||||
evaluateApproval,
|
||||
type ApprovalDecision,
|
||||
} from "./approvals.js";
|
||||
import { withLimits } from "./limits.js";
|
||||
import { emitOperationEvent } from "./events.js";
|
||||
import { dropResourceCache } from "./resources.js";
|
||||
import { loadOperationHost, type OperationHost } from "./hosts.js";
|
||||
import { runAsActor, type ActorOrigin } from "../plugins/actor.js";
|
||||
import { hasCapability } from "../plugins/permissions.js";
|
||||
import { qualifyPermission } from "../plugins/manifest.js";
|
||||
import { safeModeOn } from "../plugins/safe-mode.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { logAudit } from "../utils/audit-logger.js";
|
||||
import {
|
||||
decryptSystemSecret,
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
} from "../utils/system-secret-crypto.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentOperationRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type {
|
||||
OperationApprovalRecord,
|
||||
OperationRunRecord,
|
||||
} from "../database/repositories/operation-repository.js";
|
||||
|
||||
const MAX_OUTPUT = 32_000;
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const READ_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_APPROVAL_MINUTES = 24 * 60;
|
||||
const DRAIN_WAIT_MS = 10_000;
|
||||
|
||||
export type OperationErrorCode =
|
||||
| "NOT_FOUND"
|
||||
| "UNAVAILABLE"
|
||||
| "INVALID_INPUT"
|
||||
| "FORBIDDEN"
|
||||
| "SCOPE"
|
||||
| "NEEDS_CONFIRM"
|
||||
| "DENIED"
|
||||
| "TOO_DEEP"
|
||||
| "STALE"
|
||||
| "CONFLICT"
|
||||
| "SAFE_MODE";
|
||||
|
||||
const STATUS: Record<OperationErrorCode, number> = {
|
||||
NOT_FOUND: 404,
|
||||
UNAVAILABLE: 503,
|
||||
INVALID_INPUT: 400,
|
||||
FORBIDDEN: 403,
|
||||
SCOPE: 403,
|
||||
NEEDS_CONFIRM: 409,
|
||||
DENIED: 403,
|
||||
TOO_DEEP: 409,
|
||||
STALE: 409,
|
||||
CONFLICT: 409,
|
||||
SAFE_MODE: 409,
|
||||
};
|
||||
|
||||
/** Refused before anything ran. Carries a status so routes can pass it on. */
|
||||
export class OperationError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(
|
||||
readonly code: OperationErrorCode,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OperationError";
|
||||
this.status = STATUS[code];
|
||||
}
|
||||
}
|
||||
|
||||
/** A handler throws this when a host needs a person to sign in first. */
|
||||
export class OperationNeedsInteraction extends Error {
|
||||
readonly code = "NEEDS_INTERACTION";
|
||||
}
|
||||
|
||||
export interface OperationRequest {
|
||||
actionId: string;
|
||||
hostId?: number;
|
||||
resourceKey?: string;
|
||||
input?: unknown;
|
||||
dryRun?: boolean;
|
||||
/** A person agreed to a destructive action. */
|
||||
confirmed?: boolean;
|
||||
idempotencyKey?: string;
|
||||
batchId?: string;
|
||||
undoOf?: string;
|
||||
/** Background reads for tables and tiles. Writes no run row. */
|
||||
internal?: boolean;
|
||||
userId: string;
|
||||
origin: ActorOrigin;
|
||||
apiKeyScopes?: string[] | null;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface OperationOutcome {
|
||||
runId: string | null;
|
||||
status: string;
|
||||
summary?: string;
|
||||
changed?: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
approvalId?: string;
|
||||
}
|
||||
|
||||
interface Prepared {
|
||||
registration: OperationRegistration;
|
||||
action: OperationAction;
|
||||
pluginVersion: string;
|
||||
input: Record<string, unknown>;
|
||||
host: OperationHost | null;
|
||||
effectiveRisk: OperationRisk;
|
||||
}
|
||||
|
||||
/** The sealed copy an approval is bound to. */
|
||||
interface ApprovalSnapshot {
|
||||
runId: string;
|
||||
actionId: string;
|
||||
actionVersion: number;
|
||||
pluginVersion: string;
|
||||
hostId: number | null;
|
||||
hostUpdatedAt: string | null;
|
||||
resourceKey: string | null;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const active = new Map<
|
||||
string,
|
||||
{ pluginId: string; controller: AbortController; done: Promise<unknown> }
|
||||
>();
|
||||
const draining = new Set<string>();
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function loadedPlugin(pluginId: string) {
|
||||
const { getPluginRuntime } = await import("../plugins/index.js");
|
||||
return getPluginRuntime().loader.get(pluginId);
|
||||
}
|
||||
|
||||
async function usernameOf(userId: string): Promise<string> {
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user?.username ?? userId;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every check that does not depend on who approved it. */
|
||||
async function prepare(request: OperationRequest): Promise<Prepared> {
|
||||
if (safeModeOn()) {
|
||||
throw new OperationError("SAFE_MODE", "Actions are off in safe mode");
|
||||
}
|
||||
if ((request.depth ?? 0) > MAX_EVENT_DEPTH) {
|
||||
throw new OperationError("TOO_DEEP", "Too many actions led to this one");
|
||||
}
|
||||
|
||||
const registration = getAction(request.actionId);
|
||||
if (!registration) {
|
||||
throw new OperationError(
|
||||
"NOT_FOUND",
|
||||
`No action "${request.actionId}" is available`,
|
||||
);
|
||||
}
|
||||
const { action, pluginId } = registration;
|
||||
const plugin = await loadedPlugin(pluginId);
|
||||
if (plugin?.state !== "active" || draining.has(pluginId)) {
|
||||
throw new OperationError("UNAVAILABLE", `${pluginId} is not running`);
|
||||
}
|
||||
// Rechecked at use, so revoking the grant stops it on the next call.
|
||||
if (
|
||||
!(await hasCapability(
|
||||
pluginId,
|
||||
"operations:provide",
|
||||
plugin.manifest.capabilities,
|
||||
))
|
||||
) {
|
||||
throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`);
|
||||
}
|
||||
|
||||
const parsed = parseOperationInput(action.inputs, request.input);
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new OperationError(
|
||||
"INVALID_INPUT",
|
||||
parsed.errors.join("; "),
|
||||
parsed.errors,
|
||||
);
|
||||
}
|
||||
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const permission = qualifyPermission(pluginId, action.permission);
|
||||
if (!(await permissions.hasPermission(request.userId, permission))) {
|
||||
throw new OperationError(
|
||||
"FORBIDDEN",
|
||||
`This needs the ${permission} permission`,
|
||||
);
|
||||
}
|
||||
|
||||
let host: OperationHost | null = null;
|
||||
if (action.scope !== "server") {
|
||||
if (request.hostId === undefined) {
|
||||
throw new OperationError("INVALID_INPUT", "This action needs a host");
|
||||
}
|
||||
if (action.scope === "resource" && !request.resourceKey) {
|
||||
throw new OperationError("INVALID_INPUT", "This action needs a target");
|
||||
}
|
||||
const access = await permissions.canAccessHost(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
action.hostAccess ?? "edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
throw new OperationError(
|
||||
"FORBIDDEN",
|
||||
`You need ${action.hostAccess ?? "edit"} access to this host`,
|
||||
);
|
||||
}
|
||||
host = await loadOperationHost(request.hostId, plugin.manifest);
|
||||
if (!host) throw new OperationError("NOT_FOUND", "Host not found");
|
||||
if (!matchesWhen(action.when, host)) {
|
||||
throw new OperationError(
|
||||
"UNAVAILABLE",
|
||||
"This action does not apply to this host",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A claim by the plugin, so only signed official plugins are taken at
|
||||
// their word when rules decide what needs a person.
|
||||
const official =
|
||||
plugin.source === "bundled" || plugin.signedTier === "official";
|
||||
const effectiveRisk: OperationRisk =
|
||||
!official && action.risk === "read" ? "change" : action.risk;
|
||||
|
||||
return {
|
||||
registration,
|
||||
action,
|
||||
pluginVersion: plugin.manifest.version,
|
||||
input: parsed.values,
|
||||
host,
|
||||
effectiveRisk,
|
||||
};
|
||||
}
|
||||
|
||||
function checkKeyScope(request: OperationRequest, action: OperationAction) {
|
||||
const scopes = request.apiKeyScopes;
|
||||
if (scopes === undefined || scopes === null) return;
|
||||
if (scopes.includes("operations:run")) return;
|
||||
if (scopes.includes("operations:read") && action.risk === "read") return;
|
||||
throw new OperationError("SCOPE", "This API key may not run this action");
|
||||
}
|
||||
|
||||
function originText(origin: ActorOrigin): string {
|
||||
return JSON.stringify(origin);
|
||||
}
|
||||
|
||||
function auditRun(
|
||||
request: OperationRequest,
|
||||
username: string,
|
||||
action: OperationAction,
|
||||
host: OperationHost | null,
|
||||
success: boolean,
|
||||
errorMessage?: string,
|
||||
verb = "operation_run",
|
||||
): void {
|
||||
void logAudit({
|
||||
userId: request.userId,
|
||||
username,
|
||||
action: verb,
|
||||
resourceType: "operation",
|
||||
resourceId: action.id,
|
||||
resourceName: host?.name ?? undefined,
|
||||
details: originText(request.origin),
|
||||
success,
|
||||
errorMessage,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function execute(
|
||||
prepared: Prepared,
|
||||
request: OperationRequest,
|
||||
runId: string | null,
|
||||
username: string,
|
||||
): Promise<OperationOutcome> {
|
||||
const { action, registration, input, host } = prepared;
|
||||
const repository = createCurrentOperationRepository();
|
||||
const controller = new AbortController();
|
||||
const dryRun = request.dryRun === true;
|
||||
const timeoutMs =
|
||||
action.timeoutMs ??
|
||||
(action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
|
||||
const lines: string[] = [];
|
||||
const context = {
|
||||
hostId: request.hostId,
|
||||
resourceKey: request.resourceKey,
|
||||
runId,
|
||||
dryRun,
|
||||
signal: controller.signal,
|
||||
log: (line: string) => {
|
||||
if (lines.length < 200) lines.push(line);
|
||||
},
|
||||
};
|
||||
|
||||
const work = async () => {
|
||||
if (dryRun && action.dryRun !== "native") {
|
||||
const summary = registration.handler.describe
|
||||
? await registration.handler.describe(input, context)
|
||||
: `Would run ${action.id}`;
|
||||
return { summary, changed: false };
|
||||
}
|
||||
return registration.handler.run(input, context);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
timer.unref?.();
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
controller.signal.addEventListener("abort", () =>
|
||||
reject(new Error("The action was stopped or timed out")),
|
||||
);
|
||||
});
|
||||
aborted.catch(() => {});
|
||||
|
||||
// Settles when the handler really stops, which can be after the run gives
|
||||
// up on it. A plugin going down waits on this, not on the run.
|
||||
let handlerStopped: () => void = () => {};
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
handlerStopped = resolve;
|
||||
});
|
||||
|
||||
const startWork = () => {
|
||||
// Cancelled while waiting for a slot, so it never starts.
|
||||
if (controller.signal.aborted) {
|
||||
handlerStopped();
|
||||
return aborted;
|
||||
}
|
||||
const handling = Promise.resolve().then(work);
|
||||
void handling.finally(handlerStopped).catch(() => {});
|
||||
return Promise.race([handling, aborted]);
|
||||
};
|
||||
|
||||
const running = withLimits(
|
||||
{ hostId: request.hostId, exclusive: action.exclusive },
|
||||
() =>
|
||||
runAsActor(request.userId, "service", startWork, undefined, {
|
||||
origin: request.origin,
|
||||
apiKeyScopes: request.apiKeyScopes,
|
||||
runId: runId ?? undefined,
|
||||
depth: (request.depth ?? 0) + 1,
|
||||
}),
|
||||
);
|
||||
if (runId) {
|
||||
active.set(runId, {
|
||||
pluginId: registration.pluginId,
|
||||
controller,
|
||||
done: stopped,
|
||||
});
|
||||
}
|
||||
|
||||
let outcome: OperationOutcome;
|
||||
try {
|
||||
const result = await running;
|
||||
const data = redactFields(action.outputs, result.data);
|
||||
outcome = {
|
||||
runId,
|
||||
status: "success",
|
||||
summary: result.summary,
|
||||
changed: result.changed,
|
||||
data,
|
||||
};
|
||||
if (runId) {
|
||||
await repository.updateRunFrom(runId, "running", {
|
||||
status: "success",
|
||||
summary: capText(result.summary ?? "", 2000),
|
||||
changed: result.changed ?? null,
|
||||
output:
|
||||
data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT),
|
||||
undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null,
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const needsPerson =
|
||||
(error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION";
|
||||
const stopping = draining.has(registration.pluginId);
|
||||
const status = needsPerson
|
||||
? "needs_interaction"
|
||||
: stopping
|
||||
? "interrupted"
|
||||
: "failed";
|
||||
const message = stopping
|
||||
? "The plugin stopped during this run. Check the host."
|
||||
: capText(errorText(error), 2000);
|
||||
outcome = { runId, status, error: message };
|
||||
if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION";
|
||||
if (runId) {
|
||||
await repository.updateRunFrom(runId, "running", {
|
||||
status,
|
||||
error: message,
|
||||
errorCode: outcome.errorCode ?? null,
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (runId && !draining.has(registration.pluginId)) active.delete(runId);
|
||||
}
|
||||
|
||||
if (action.risk !== "read" && !dryRun) {
|
||||
if (request.hostId !== undefined && outcome.status === "success") {
|
||||
dropResourceCache(request.hostId);
|
||||
}
|
||||
auditRun(
|
||||
request,
|
||||
username,
|
||||
action,
|
||||
host,
|
||||
outcome.status === "success",
|
||||
outcome.error,
|
||||
);
|
||||
void emitOperationEvent({
|
||||
eventId:
|
||||
outcome.status === "success"
|
||||
? "operation.succeeded"
|
||||
: "operation.failed",
|
||||
pluginId: registration.pluginId,
|
||||
hostId: request.hostId,
|
||||
severity: outcome.status === "success" ? "info" : "warning",
|
||||
payload: {
|
||||
actionId: action.id,
|
||||
summary: outcome.summary,
|
||||
error: outcome.error,
|
||||
origin: request.origin,
|
||||
},
|
||||
runId: runId ?? undefined,
|
||||
depth: (request.depth ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function outcomeOf(
|
||||
run: OperationRunRecord,
|
||||
approvalId?: string,
|
||||
): OperationOutcome {
|
||||
return {
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
summary: run.summary ?? undefined,
|
||||
changed: run.changed ?? undefined,
|
||||
error: run.error ?? undefined,
|
||||
errorCode: run.errorCode ?? undefined,
|
||||
approvalId,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestApproval(
|
||||
prepared: Prepared,
|
||||
request: OperationRequest,
|
||||
decision: ApprovalDecision,
|
||||
run: { id: string; username: string; createdAt: string },
|
||||
): Promise<OperationOutcome> {
|
||||
const { action, host, input } = prepared;
|
||||
const repository = createCurrentOperationRepository();
|
||||
const snapshot: ApprovalSnapshot = {
|
||||
runId: run.id,
|
||||
actionId: action.id,
|
||||
actionVersion: action.version ?? 1,
|
||||
pluginVersion: prepared.pluginVersion,
|
||||
hostId: request.hostId ?? null,
|
||||
hostUpdatedAt: host?.updatedAt ?? null,
|
||||
resourceKey: request.resourceKey ?? null,
|
||||
input,
|
||||
};
|
||||
await repository.updateRunFrom(run.id, "created", {
|
||||
status: "pending_approval",
|
||||
summary: decision.reason ?? null,
|
||||
sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)),
|
||||
});
|
||||
const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES;
|
||||
const approvalId = randomUUID();
|
||||
await repository.insertApproval({
|
||||
id: approvalId,
|
||||
runId: run.id,
|
||||
requestedBy: request.userId,
|
||||
reason: decision.reason ?? null,
|
||||
allowSelf: decision.allowSelf === true || canSelfApprove(request.origin),
|
||||
status: "pending",
|
||||
createdAt: run.createdAt,
|
||||
expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
|
||||
});
|
||||
void emitOperationEvent({
|
||||
eventId: "operation.approval-requested",
|
||||
pluginId: prepared.registration.pluginId,
|
||||
hostId: request.hostId,
|
||||
severity: "warning",
|
||||
payload: { actionId: action.id, approvalId },
|
||||
runId: run.id,
|
||||
});
|
||||
return { runId: run.id, status: "pending_approval", approvalId };
|
||||
}
|
||||
|
||||
/** Runs an action, or parks it for approval. Throws OperationError if refused. */
|
||||
export async function runOperation(
|
||||
request: OperationRequest,
|
||||
): Promise<OperationOutcome> {
|
||||
const registration = getAction(request.actionId);
|
||||
if (registration) checkKeyScope(request, registration.action);
|
||||
|
||||
const repository = createCurrentOperationRepository();
|
||||
if (request.idempotencyKey) {
|
||||
const earlier = await repository.findByIdempotencyKey(
|
||||
request.userId,
|
||||
request.idempotencyKey,
|
||||
);
|
||||
if (earlier) return outcomeOf(earlier);
|
||||
}
|
||||
|
||||
const prepared = await prepare(request);
|
||||
const { action } = prepared;
|
||||
const dryRun = request.dryRun === true;
|
||||
const isRead = action.risk === "read";
|
||||
|
||||
if (action.risk === "destructive" && !dryRun && !request.confirmed) {
|
||||
throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm");
|
||||
}
|
||||
|
||||
let decision: ApprovalDecision = { decision: "allow" };
|
||||
if (!dryRun && !request.internal) {
|
||||
decision = await evaluateApproval({
|
||||
actionId: action.id,
|
||||
pluginId: prepared.registration.pluginId,
|
||||
risk: action.risk,
|
||||
effectiveRisk: prepared.effectiveRisk,
|
||||
origin: request.origin,
|
||||
userId: request.userId,
|
||||
hostId: request.hostId,
|
||||
hostTags: prepared.host?.tags,
|
||||
batchId: request.batchId,
|
||||
});
|
||||
}
|
||||
const username = await usernameOf(request.userId);
|
||||
if (decision.decision === "deny") {
|
||||
auditRun(
|
||||
request,
|
||||
username,
|
||||
action,
|
||||
prepared.host,
|
||||
false,
|
||||
decision.reason,
|
||||
"operation_denied",
|
||||
);
|
||||
throw new OperationError(
|
||||
"DENIED",
|
||||
decision.reason ?? "A rule refused this action",
|
||||
);
|
||||
}
|
||||
|
||||
// Internal reads poll constantly and would flood history and the save loop.
|
||||
if (request.internal && isRead) {
|
||||
return execute(prepared, request, null, username);
|
||||
}
|
||||
|
||||
const run = {
|
||||
id: randomUUID(),
|
||||
actionId: action.id,
|
||||
actionVersion: action.version ?? 1,
|
||||
pluginId: prepared.registration.pluginId,
|
||||
pluginVersion: prepared.pluginVersion,
|
||||
userId: request.userId,
|
||||
username,
|
||||
origin: originText(request.origin),
|
||||
hostId: request.hostId ?? null,
|
||||
hostName: prepared.host?.name ?? null,
|
||||
batchId: request.batchId ?? null,
|
||||
dryRun,
|
||||
risk: action.risk,
|
||||
status: decision.decision === "approve" ? "created" : "running",
|
||||
input: JSON.stringify(redactFields(action.inputs, prepared.input)),
|
||||
undoOf: request.undoOf ?? null,
|
||||
idempotencyKey: request.idempotencyKey ?? null,
|
||||
createdAt: now(),
|
||||
startedAt: decision.decision === "approve" ? null : now(),
|
||||
};
|
||||
await repository.insertRun(run);
|
||||
|
||||
if (decision.decision === "approve") {
|
||||
return requestApproval(prepared, request, decision, run);
|
||||
}
|
||||
return execute(prepared, request, run.id, username);
|
||||
}
|
||||
|
||||
function originOf(run: OperationRunRecord): ActorOrigin {
|
||||
try {
|
||||
return JSON.parse(run.origin) as ActorOrigin;
|
||||
} catch {
|
||||
return { kind: "api" };
|
||||
}
|
||||
}
|
||||
|
||||
async function failStale(run: OperationRunRecord, why: string): Promise<void> {
|
||||
await createCurrentOperationRepository().updateRunFrom(
|
||||
run.id,
|
||||
"pending_approval",
|
||||
{
|
||||
status: "rejected",
|
||||
error: why,
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a person's decision. One shot: the row moves from pending once, so a
|
||||
* second click or a replay does nothing. What runs is the sealed copy, never
|
||||
* what the caller sends now.
|
||||
*/
|
||||
export async function decideOperationApproval(options: {
|
||||
approvalId: string;
|
||||
approverId: string;
|
||||
approve: boolean;
|
||||
comment?: string;
|
||||
impersonated?: boolean;
|
||||
}): Promise<OperationOutcome> {
|
||||
const { approvalId, approverId, approve, comment } = options;
|
||||
const repository = createCurrentOperationRepository();
|
||||
const approval = await repository.findApproval(approvalId);
|
||||
if (!approval) throw new OperationError("NOT_FOUND", "Approval not found");
|
||||
if (approval.status !== "pending") {
|
||||
throw new OperationError("CONFLICT", "This was already decided");
|
||||
}
|
||||
const run = await repository.findRun(approval.runId);
|
||||
if (!run) throw new OperationError("NOT_FOUND", "Run not found");
|
||||
|
||||
if (options.impersonated) {
|
||||
throw new OperationError("FORBIDDEN", "You cannot approve as another user");
|
||||
}
|
||||
if (approval.requestedBy === approverId && !approval.allowSelf) {
|
||||
throw new OperationError(
|
||||
"FORBIDDEN",
|
||||
"Someone else has to approve your request",
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date(approval.expiresAt).getTime() < Date.now()) {
|
||||
await repository.decideApproval(approvalId, "expired", null, null, now());
|
||||
await failStale(run, "The approval expired");
|
||||
throw new OperationError("STALE", "This approval expired");
|
||||
}
|
||||
|
||||
// Approving needs the same rights as running it.
|
||||
const registration = getAction(run.actionId);
|
||||
if (registration) {
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const needed = qualifyPermission(
|
||||
registration.pluginId,
|
||||
registration.action.permission,
|
||||
);
|
||||
const allowed =
|
||||
(await permissions.hasPermission(approverId, needed)) &&
|
||||
(run.hostId === null ||
|
||||
(
|
||||
await permissions.canAccessHost(
|
||||
approverId,
|
||||
run.hostId,
|
||||
registration.action.hostAccess ?? "edit",
|
||||
)
|
||||
).hasAccess);
|
||||
if (!allowed) {
|
||||
throw new OperationError(
|
||||
"FORBIDDEN",
|
||||
"You need the same access as this action to approve it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const won = await repository.decideApproval(
|
||||
approvalId,
|
||||
approve ? "approved" : "rejected",
|
||||
approverId,
|
||||
comment?.slice(0, 500) ?? null,
|
||||
now(),
|
||||
);
|
||||
if (!won) throw new OperationError("CONFLICT", "This was already decided");
|
||||
|
||||
const username = await usernameOf(approverId);
|
||||
void logAudit({
|
||||
userId: approverId,
|
||||
username,
|
||||
action: approve ? "operation_approved" : "operation_rejected",
|
||||
resourceType: "operation",
|
||||
resourceId: run.actionId,
|
||||
details: run.id,
|
||||
success: true,
|
||||
}).catch(() => {});
|
||||
|
||||
if (!approve) {
|
||||
await repository.updateRunFrom(run.id, "pending_approval", {
|
||||
status: "rejected",
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
});
|
||||
return { runId: run.id, status: "rejected" };
|
||||
}
|
||||
return runApproved(run, approval);
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
run: OperationRunRecord,
|
||||
): Promise<ApprovalSnapshot | null> {
|
||||
if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null;
|
||||
try {
|
||||
const snapshot = JSON.parse(
|
||||
await decryptSystemSecret(run.sealedInput),
|
||||
) as ApprovalSnapshot;
|
||||
return snapshot.runId === run.id ? snapshot : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runApproved(
|
||||
run: OperationRunRecord,
|
||||
approval: OperationApprovalRecord,
|
||||
): Promise<OperationOutcome> {
|
||||
const snapshot = await readSnapshot(run);
|
||||
if (!snapshot || !run.userId) {
|
||||
await failStale(run, "The approved request could not be read");
|
||||
throw new OperationError("STALE", "The approved request could not be read");
|
||||
}
|
||||
|
||||
const request: OperationRequest = {
|
||||
actionId: snapshot.actionId,
|
||||
hostId: snapshot.hostId ?? undefined,
|
||||
resourceKey: snapshot.resourceKey ?? undefined,
|
||||
input: snapshot.input,
|
||||
confirmed: true,
|
||||
batchId: run.batchId ?? undefined,
|
||||
userId: run.userId,
|
||||
origin: originOf(run),
|
||||
};
|
||||
|
||||
// The requester's own rights, as of now. They may have changed.
|
||||
let prepared: Prepared;
|
||||
try {
|
||||
prepared = await prepare(request);
|
||||
} catch (error) {
|
||||
await failStale(run, errorText(error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
const changedSince =
|
||||
prepared.pluginVersion !== snapshot.pluginVersion ||
|
||||
(prepared.action.version ?? 1) !== snapshot.actionVersion ||
|
||||
(prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt;
|
||||
if (changedSince) {
|
||||
await failStale(run, "The host or plugin changed after this was requested");
|
||||
throw new OperationError(
|
||||
"STALE",
|
||||
"The host or plugin changed after this was requested",
|
||||
);
|
||||
}
|
||||
|
||||
const repository = createCurrentOperationRepository();
|
||||
const claimed = await repository.updateRunFrom(run.id, "pending_approval", {
|
||||
status: "running",
|
||||
startedAt: now(),
|
||||
});
|
||||
if (!claimed) throw new OperationError("CONFLICT", "This already ran");
|
||||
|
||||
const outcome = await execute(prepared, request, run.id, run.username);
|
||||
return { ...outcome, approvalId: approval.id };
|
||||
}
|
||||
|
||||
export async function cancelOperationRun(
|
||||
run: OperationRunRecord,
|
||||
): Promise<boolean> {
|
||||
const repository = createCurrentOperationRepository();
|
||||
if (run.status === "pending_approval") {
|
||||
const approval = await repository.findApprovalForRun(run.id);
|
||||
if (approval) {
|
||||
await repository.decideApproval(
|
||||
approval.id,
|
||||
"rejected",
|
||||
null,
|
||||
"cancelled",
|
||||
now(),
|
||||
);
|
||||
}
|
||||
return repository.updateRunFrom(run.id, "pending_approval", {
|
||||
status: "cancelled",
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
});
|
||||
}
|
||||
const running = active.get(run.id);
|
||||
if (!running) return false;
|
||||
running.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a plugin stops, before its SSH connections are torn down. New
|
||||
* runs are refused and running ones told to stop. Anything still going after
|
||||
* the wait has an unknown outcome and is never retried.
|
||||
*/
|
||||
export async function drainPlugin(pluginId: string): Promise<void> {
|
||||
draining.add(pluginId);
|
||||
try {
|
||||
const runs = [...active.entries()].filter(
|
||||
([, entry]) => entry.pluginId === pluginId,
|
||||
);
|
||||
if (runs.length === 0) return;
|
||||
for (const [, entry] of runs) entry.controller.abort();
|
||||
await Promise.race([
|
||||
Promise.all(runs.map(([, entry]) => entry.done)),
|
||||
new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)),
|
||||
]);
|
||||
const repository = createCurrentOperationRepository();
|
||||
for (const [runId] of runs) {
|
||||
await repository.updateRunFrom(runId, "running", {
|
||||
status: "interrupted",
|
||||
error: "The plugin stopped during this run. Check the host.",
|
||||
sealedInput: null,
|
||||
finishedAt: now(),
|
||||
});
|
||||
active.delete(runId);
|
||||
}
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Could not finish stopping runs", {
|
||||
operation: "operation_drain",
|
||||
pluginId,
|
||||
error: errorText(error),
|
||||
});
|
||||
} finally {
|
||||
draining.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
export function riskAtLeast(
|
||||
risk: OperationRisk,
|
||||
floor: OperationRisk,
|
||||
): boolean {
|
||||
return riskRank(risk) >= riskRank(floor);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearOperationGate(): void {
|
||||
active.clear();
|
||||
draining.clear();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The small view of a host the operations layer needs: enough to name it, test
|
||||
* an action's `when`, and notice it changed. No secret field is ever read here.
|
||||
*/
|
||||
|
||||
import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations";
|
||||
import type { PluginManifest } from "../plugins/manifest.js";
|
||||
import { getSetting } from "../plugins/settings.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface OperationHost {
|
||||
id: number;
|
||||
name: string;
|
||||
updatedAt: string | null;
|
||||
tags: string[];
|
||||
os: string | null;
|
||||
/** Only the setting keys some `when` asks about. */
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function splitTags(tags: string | null | undefined): string[] {
|
||||
return (tags ?? "")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function settingsFor(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const keys = new Set<string>();
|
||||
for (const action of manifest.contributes?.operations?.actions ?? []) {
|
||||
for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key);
|
||||
}
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
settings[key] = await getSetting(manifest, "host", hostId, key);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** A host with the settings its plugin's `when` rules read. */
|
||||
export async function loadOperationHost(
|
||||
hostId: number,
|
||||
manifest: PluginManifest,
|
||||
): Promise<OperationHost | null> {
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const ownerId = await repository.findHostOwnerId(hostId);
|
||||
if (!ownerId) return null;
|
||||
const host = (await repository.findHostById(hostId, ownerId)) as {
|
||||
name: string | null;
|
||||
ip: string;
|
||||
tags: string | null;
|
||||
updatedAt?: string | null;
|
||||
} | null;
|
||||
if (!host) return null;
|
||||
return {
|
||||
id: hostId,
|
||||
name: host.name || host.ip,
|
||||
updatedAt: host.updatedAt ?? null,
|
||||
tags: splitTags(host.tags),
|
||||
os: null,
|
||||
settings: await settingsFor(manifest, hostId),
|
||||
};
|
||||
}
|
||||
|
||||
export type { OperationWhen };
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* What a scoped API key may reach. Enforced where keys are authenticated, so a
|
||||
* limited key cannot step around the gate through /plugin-api or a ws ticket. A
|
||||
* key with no scopes has full access, which is every key made before this.
|
||||
*/
|
||||
|
||||
export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const;
|
||||
|
||||
const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/;
|
||||
const OPERATIONS_PATH = /^\/operations(\/|$)/;
|
||||
|
||||
export function parseKeyScopes(
|
||||
raw: string | null | undefined,
|
||||
): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return Array.isArray(value)
|
||||
? value.filter((scope): scope is string => typeof scope === "string")
|
||||
: [];
|
||||
} catch {
|
||||
// Unreadable means no access, not full access.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function keyScopeAllows(
|
||||
scopes: string[] | null,
|
||||
method: string,
|
||||
path: string,
|
||||
): boolean {
|
||||
if (scopes === null) return true;
|
||||
const clean = path.split("?")[0];
|
||||
if (MCP_PATH.test(clean)) {
|
||||
return (
|
||||
scopes.includes("operations:read") || scopes.includes("operations:run")
|
||||
);
|
||||
}
|
||||
if (!OPERATIONS_PATH.test(clean)) return false;
|
||||
if (scopes.includes("operations:run")) return true;
|
||||
if (!scopes.includes("operations:read")) return false;
|
||||
// The gate still checks each action's risk on a run.
|
||||
return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Caps on how much runs at once. The SSH pool caps per key, not per host, so a
|
||||
* large rollout or an all hosts table needs its own limit here.
|
||||
*/
|
||||
|
||||
export const MAX_RUNNING = 32;
|
||||
export const MAX_PER_HOST = 3;
|
||||
|
||||
class Slots {
|
||||
private used = 0;
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly size: number) {}
|
||||
|
||||
async take(): Promise<void> {
|
||||
if (this.used < this.size) {
|
||||
this.used++;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
||||
}
|
||||
|
||||
give(): void {
|
||||
const next = this.waiting.shift();
|
||||
if (next) next();
|
||||
else this.used--;
|
||||
}
|
||||
|
||||
get idle(): boolean {
|
||||
return this.used === 0 && this.waiting.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
const everything = new Slots(MAX_RUNNING);
|
||||
const perHost = new Map<number, Slots>();
|
||||
const exclusive = new Map<number, Slots>();
|
||||
|
||||
function slotsFor(
|
||||
map: Map<number, Slots>,
|
||||
hostId: number,
|
||||
size: number,
|
||||
): Slots {
|
||||
let slots = map.get(hostId);
|
||||
if (!slots) {
|
||||
slots = new Slots(size);
|
||||
map.set(hostId, slots);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */
|
||||
export async function withLimits<T>(
|
||||
options: { hostId?: number; exclusive?: boolean },
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { hostId } = options;
|
||||
const held: { map?: Map<number, Slots>; slots: Slots }[] = [];
|
||||
const hold = async (slots: Slots, map?: Map<number, Slots>) => {
|
||||
await slots.take();
|
||||
held.push({ slots, map });
|
||||
};
|
||||
|
||||
try {
|
||||
// Same order everywhere, so two runs cannot wait on each other.
|
||||
if (hostId !== undefined && options.exclusive) {
|
||||
await hold(slotsFor(exclusive, hostId, 1), exclusive);
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost);
|
||||
}
|
||||
await hold(everything);
|
||||
return await work();
|
||||
} finally {
|
||||
for (const { slots, map } of held.reverse()) {
|
||||
slots.give();
|
||||
if (map && hostId !== undefined && slots.idle) map.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shares one in-flight promise per key, so one read serves every waiter. */
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function coalesce<T>(key: string, work: () => Promise<T>): Promise<T> {
|
||||
const running = inflight.get(key);
|
||||
if (running) return running as Promise<T>;
|
||||
const started = work().finally(() => inflight.delete(key));
|
||||
inflight.set(key, started);
|
||||
return started;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Startup sweep and periodic cleanup for runs, approvals and events. */
|
||||
|
||||
import { RETENTION_DAYS } from "./constants.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
|
||||
|
||||
const SWEEP_MS = 10 * 60_000;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Parks expired approvals and trims history. Returns rows removed. */
|
||||
export async function sweepOperations(at = new Date()): Promise<number> {
|
||||
const repository = createCurrentOperationRepository();
|
||||
const iso = at.toISOString();
|
||||
for (const approval of await repository.listExpiredApprovals(iso)) {
|
||||
await repository.decideApproval(approval.id, "expired", null, null, iso);
|
||||
await repository.updateRunFrom(approval.runId, "pending_approval", {
|
||||
status: "expired",
|
||||
sealedInput: null,
|
||||
finishedAt: iso,
|
||||
});
|
||||
}
|
||||
const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000);
|
||||
return repository.prune(cutoff.toISOString());
|
||||
}
|
||||
|
||||
/** Anything still running at boot lost its process, so its outcome is unknown. */
|
||||
export async function startOperationsMaintenance(): Promise<void> {
|
||||
await createCurrentOperationRepository().markRunningInterrupted(
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await sweepOperations();
|
||||
timer ??= setInterval(() => {
|
||||
sweepOperations().catch((error) => {
|
||||
pluginLogger.warn("Operations cleanup failed", {
|
||||
operation: "operations_sweep",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}, SWEEP_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function stopOperationsMaintenance(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Handlers plugins registered for the actions their manifest declares.
|
||||
*
|
||||
* Same generation rule as service-registry.ts: a plugin that crashed and
|
||||
* restarted must not have its old disposer remove the new registration, and a
|
||||
* run that resolved a handler before a reload must not call the stale one.
|
||||
*/
|
||||
|
||||
import type {
|
||||
OperationAction,
|
||||
OperationResult,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
|
||||
export interface OperationRunContext {
|
||||
hostId?: number;
|
||||
resourceKey?: string;
|
||||
runId: string | null;
|
||||
dryRun: boolean;
|
||||
signal: AbortSignal;
|
||||
log: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface OperationHandler {
|
||||
run: (
|
||||
input: Record<string, unknown>,
|
||||
context: OperationRunContext,
|
||||
) => Promise<OperationResult> | OperationResult;
|
||||
/** What a dry run reports when the action has no native dry run. */
|
||||
describe?: (
|
||||
input: Record<string, unknown>,
|
||||
context: OperationRunContext,
|
||||
) => string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface OperationRegistration {
|
||||
pluginId: string;
|
||||
action: OperationAction;
|
||||
handler: OperationHandler;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const registrations = new Map<string, OperationRegistration>();
|
||||
let generationCounter = 0;
|
||||
|
||||
export function registerAction(
|
||||
pluginId: string,
|
||||
action: OperationAction,
|
||||
handler: OperationHandler,
|
||||
): OperationRegistration {
|
||||
const existing = registrations.get(action.id);
|
||||
if (existing && existing.pluginId !== pluginId) {
|
||||
throw new Error(
|
||||
`Action "${action.id}" is already registered by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
const entry = {
|
||||
pluginId,
|
||||
action,
|
||||
handler,
|
||||
generation: ++generationCounter,
|
||||
};
|
||||
registrations.set(action.id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Removes only the exact registration given. */
|
||||
export function revokeAction(registration: OperationRegistration): boolean {
|
||||
const current = registrations.get(registration.action.id);
|
||||
if (!current || current.generation !== registration.generation) return false;
|
||||
return registrations.delete(registration.action.id);
|
||||
}
|
||||
|
||||
export function getAction(id: string): OperationRegistration | undefined {
|
||||
return registrations.get(id);
|
||||
}
|
||||
|
||||
export function listActions(): OperationRegistration[] {
|
||||
return [...registrations.values()];
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearOperationRegistry(): void {
|
||||
registrations.clear();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Reads for All hosts tables and status tiles.
|
||||
*
|
||||
* Asking every host over SSH on each page load cannot hold up, so a request
|
||||
* answers right away from memory and refreshes stale hosts behind it. The page
|
||||
* polls and the table fills in as hosts answer. Results stay in memory, never
|
||||
* SQLite, since a save rewrites the whole database.
|
||||
*
|
||||
* Keys include the user: someone a host is shared with can have their own
|
||||
* login there and see different rows.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ResourceListResult,
|
||||
SignalResult,
|
||||
SignalStatus,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
import { RESOURCE_FRESH_MS } from "./constants.js";
|
||||
import { coalesce } from "./limits.js";
|
||||
import { getAction } from "./registry.js";
|
||||
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
|
||||
import { loadOperationHost } from "./hosts.js";
|
||||
import type { ActorOrigin } from "../plugins/actor.js";
|
||||
import { hostStatus } from "../plugins/host-status-provider.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { loadProbeTargets } from "../plugins/ctx-probe.js";
|
||||
|
||||
export type HostReadState =
|
||||
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
|
||||
|
||||
interface CacheEntry {
|
||||
value: unknown;
|
||||
error?: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface HostRead<T> {
|
||||
hostId: number;
|
||||
state: HostReadState;
|
||||
value?: T;
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const MAX_ENTRIES = 20_000;
|
||||
|
||||
function cacheKey(userId: string, actionId: string, hostId: number): string {
|
||||
return `${userId}|${actionId}|${hostId}`;
|
||||
}
|
||||
|
||||
/** After a change, the next read of this host goes back to the host. */
|
||||
export function dropResourceCache(hostId: number): void {
|
||||
const suffix = `|${hostId}`;
|
||||
for (const key of cache.keys()) {
|
||||
if (key.endsWith(suffix)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, entry: CacheEntry): void {
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
function isOffline(hostId: number): boolean {
|
||||
const status = hostStatus.get(hostId) as
|
||||
string | { status?: string } | null | undefined;
|
||||
const value = typeof status === "string" ? status : status?.status;
|
||||
return value === "offline";
|
||||
}
|
||||
|
||||
interface ReadOptions {
|
||||
userId: string;
|
||||
actionId: string;
|
||||
hostIds?: number[];
|
||||
refresh?: boolean;
|
||||
input?: Record<string, unknown>;
|
||||
origin: ActorOrigin;
|
||||
}
|
||||
|
||||
function refreshHost(
|
||||
options: ReadOptions,
|
||||
hostId: number,
|
||||
key: string,
|
||||
): Promise<unknown> {
|
||||
return coalesce(key, async () => {
|
||||
const { runOperation, OperationError } = await import("./gate.js");
|
||||
try {
|
||||
const outcome = await runOperation({
|
||||
actionId: options.actionId,
|
||||
hostId,
|
||||
input: options.input,
|
||||
internal: true,
|
||||
userId: options.userId,
|
||||
origin: options.origin,
|
||||
});
|
||||
if (outcome.status !== "success") {
|
||||
remember(key, {
|
||||
value: undefined,
|
||||
error: outcome.error ?? outcome.status,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
remember(key, { value: outcome.data, fetchedAt: Date.now() });
|
||||
} catch (error) {
|
||||
remember(key, {
|
||||
value: undefined,
|
||||
error:
|
||||
error instanceof OperationError || error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One entry per host the user can reach. Never waits on a host. */
|
||||
export async function readAcrossHosts<T>(
|
||||
options: ReadOptions,
|
||||
): Promise<HostRead<T>[]> {
|
||||
const registration = getAction(options.actionId);
|
||||
if (!registration) return [];
|
||||
const { action } = registration;
|
||||
const { getPluginRuntime } = await import("../plugins/index.js");
|
||||
const manifest = getPluginRuntime().loader.get(
|
||||
registration.pluginId,
|
||||
)?.manifest;
|
||||
if (!manifest) return [];
|
||||
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const targets = await loadProbeTargets(options.userId, options.hostIds);
|
||||
const reads: HostRead<T>[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const hostId = target.id;
|
||||
const access = await permissions.canAccessHost(
|
||||
options.userId,
|
||||
hostId,
|
||||
action.hostAccess ?? "connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
reads.push({ hostId, state: "no-access" });
|
||||
continue;
|
||||
}
|
||||
if (action.when) {
|
||||
const host = await loadOperationHost(hostId, manifest);
|
||||
if (!host || !matchesWhen(action.when, host)) continue;
|
||||
}
|
||||
|
||||
const key = cacheKey(options.userId, options.actionId, hostId);
|
||||
const cached = cache.get(key);
|
||||
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
|
||||
|
||||
if (isOffline(hostId)) {
|
||||
reads.push({
|
||||
hostId,
|
||||
state: "offline",
|
||||
value: cached?.value as T | undefined,
|
||||
fetchedAt: cached
|
||||
? new Date(cached.fetchedAt).toISOString()
|
||||
: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
|
||||
if (!cached) {
|
||||
reads.push({ hostId, state: "loading" });
|
||||
} else if (cached.error) {
|
||||
reads.push({
|
||||
hostId,
|
||||
state: "error",
|
||||
error: cached.error,
|
||||
fetchedAt: new Date(cached.fetchedAt).toISOString(),
|
||||
});
|
||||
} else {
|
||||
reads.push({
|
||||
hostId,
|
||||
state: "ok",
|
||||
value: cached.value as T,
|
||||
fetchedAt: new Date(cached.fetchedAt).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
export interface ResourceRow {
|
||||
hostId: number;
|
||||
key: string;
|
||||
row: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Flattens per host reads into table rows, keeping each host's state. */
|
||||
export function flattenResourceRows(
|
||||
reads: HostRead<ResourceListResult>[],
|
||||
keyField: string,
|
||||
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
|
||||
const rows: ResourceRow[] = [];
|
||||
const hosts: Omit<HostRead<unknown>, "value">[] = [];
|
||||
for (const read of reads) {
|
||||
const { value, ...state } = read;
|
||||
hosts.push(state);
|
||||
for (const row of value?.rows ?? []) {
|
||||
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
|
||||
}
|
||||
}
|
||||
return { rows, hosts };
|
||||
}
|
||||
|
||||
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
|
||||
|
||||
/** The worst status across hosts, for a server level tile. */
|
||||
export function worstStatus(
|
||||
results: (SignalResult | undefined)[],
|
||||
): SignalStatus {
|
||||
for (const status of WORST) {
|
||||
if (results.some((result) => result?.status === status)) return status;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearResourceCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
Reference in new issue
Block a user