feat: add the operations layer for declared plugin actions

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

No files matched your search

+2
View File
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import operationsRoutes from "./routes/operations-routes.js";
import {
mountPluginApi,
mountPluginLegacyPaths,
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes);
app.use("/operations", operationsRoutes);
app.use("/plugins", pluginRoutes);
app.use(
"/plugin-assets",
+65
View File
@@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (role_name, permission)
);
CREATE TABLE IF NOT EXISTS operation_runs (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
action_version INTEGER NOT NULL DEFAULT 1,
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
username TEXT NOT NULL,
origin TEXT NOT NULL,
host_id INTEGER,
host_name TEXT,
batch_id TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
risk TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
changed INTEGER,
input TEXT,
sealed_input TEXT,
output TEXT,
undo_input TEXT,
error TEXT,
error_code TEXT,
undo_of TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
CREATE TABLE IF NOT EXISTS operation_approvals (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
requested_by TEXT,
reason TEXT,
allow_self INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
decided_by TEXT,
comment TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
CREATE TABLE IF NOT EXISTS operation_events (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
host_id INTEGER,
severity TEXT,
payload TEXT,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => {
const migrateSchema = () => {
repairSnippetsColumns(sqlite);
addColumnIfNotExists("api_keys", "scopes", "TEXT");
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = mysqlTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = mysqlTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: int("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: int("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = mysqlTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = mysqlTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: int("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = mysqlTable(
"user_open_tabs",
{
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = pgTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = pgTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = pgTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = pgTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = pgTable(
"user_open_tabs",
{
+92
View File
@@ -663,10 +663,102 @@ export const apiKeys = sqliteTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = sqliteTable(
"operation_runs",
{
id: text("id").primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: text("batch_id"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
risk: text("risk").notNull(),
status: text("status").notNull(),
summary: text("summary"),
changed: integer("changed", { mode: "boolean" }),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = sqliteTable(
"operation_approvals",
{
id: text("id").primaryKey(),
runId: text("run_id")
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: integer("allow_self", { mode: "boolean" })
.notNull()
.default(false),
status: text("status").notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = sqliteTable(
"operation_events",
{
id: text("id").primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: text("created_at").notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = sqliteTable(
"user_open_tabs",
{
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OperationRepository } from "./operation-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
);
}
export function createCurrentOperationRepository(): OperationRepository {
return new OperationRepository(
createCurrentRepositoryContext(),
// Rows land on every run. A rollout saves once on its own when it starts.
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,255 @@
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
import {
operationApprovals,
operationEvents,
operationRuns,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type OperationRunRecord = typeof operationRuns.$inferSelect;
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
export type OperationEventRecord = typeof operationEvents.$inferSelect;
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
export interface OperationRunFilters {
userId?: string;
/** Rows also visible because the viewer owns the host. */
orHostIds?: number[];
hostId?: number;
actionId?: string;
status?: string;
batchId?: string;
before?: string;
}
const MAX_PAGE = 200;
export class OperationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async insertRun(run: NewOperationRunRecord): Promise<void> {
await this.context.drizzle.insert(operationRuns).values(run);
await this.onWrite?.();
}
async findRun(id: string): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(eq(operationRuns.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByIdempotencyKey(
userId: string,
key: string,
): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(
and(
eq(operationRuns.userId, userId),
eq(operationRuns.idempotencyKey, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async listRuns(
filters: OperationRunFilters,
limit = 50,
): Promise<OperationRunRecord[]> {
const owner = filters.userId
? filters.orHostIds?.length
? or(
eq(operationRuns.userId, filters.userId),
inArray(operationRuns.hostId, filters.orHostIds),
)
: eq(operationRuns.userId, filters.userId)
: undefined;
const conditions = [
owner,
filters.hostId !== undefined
? eq(operationRuns.hostId, filters.hostId)
: undefined,
filters.actionId
? eq(operationRuns.actionId, filters.actionId)
: undefined,
filters.status ? eq(operationRuns.status, filters.status) : undefined,
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationRuns)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationRuns.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Moves a run on only from the status given, so two writers cannot race. */
async updateRunFrom(
id: string,
fromStatus: string,
patch: Partial<NewOperationRunRecord>,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationRuns)
.set(patch)
.where(
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
/** Anything still running when the server stopped has an unknown outcome. */
async markRunningInterrupted(finishedAt: string): Promise<number> {
const result = await this.context.drizzle
.update(operationRuns)
.set({
status: "interrupted",
finishedAt,
sealedInput: null,
error: "The server stopped during this run. Check the host.",
})
.where(eq(operationRuns.status, "running"));
await this.onWrite?.();
return rowsAffected(result);
}
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
await this.context.drizzle.insert(operationApprovals).values(approval);
await this.onWrite?.();
}
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.id, id))
.limit(1);
return rows[0] ?? null;
}
async findApprovalForRun(
runId: string,
): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.runId, runId))
.limit(1);
return rows[0] ?? null;
}
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.status, "pending"))
.orderBy(desc(operationApprovals.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** One shot: only the first decision on a pending row wins. */
async decideApproval(
id: string,
status: "approved" | "rejected" | "expired",
decidedBy: string | null,
comment: string | null,
decidedAt: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationApprovals)
.set({ status, decidedBy, comment, decidedAt })
.where(
and(
eq(operationApprovals.id, id),
eq(operationApprovals.status, "pending"),
),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(
and(
eq(operationApprovals.status, "pending"),
lt(operationApprovals.expiresAt, now),
),
)
.limit(MAX_PAGE);
}
async insertEvent(event: NewOperationEventRecord): Promise<void> {
await this.context.drizzle.insert(operationEvents).values(event);
await this.onWrite?.();
}
async listEvents(
filters: { hostId?: number; before?: string },
limit = 50,
): Promise<OperationEventRecord[]> {
const conditions = [
filters.hostId !== undefined
? eq(operationEvents.hostId, filters.hostId)
: undefined,
filters.before
? lt(operationEvents.createdAt, filters.before)
: undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Drops rows older than the cutoff, finished runs only. */
async prune(before: string): Promise<number> {
const runs = await this.context.drizzle
.delete(operationRuns)
.where(
and(
lt(operationRuns.createdAt, before),
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
),
);
const events = await this.context.drizzle
.delete(operationEvents)
.where(lt(operationEvents.createdAt, before));
await this.onWrite?.();
return rowsAffected(runs) + rowsAffected(events);
}
async countRuns(): Promise<number> {
const rows = await this.context.drizzle
.select({ value: sql<number>`count(*)` })
.from(operationRuns);
return countValue(rows[0]?.value);
}
/** History outlives the account, but the name goes with it. */
async anonymizeUser(userId: string): Promise<void> {
await this.context.drizzle
.update(operationRuns)
.set({ username: "deleted user" })
.where(eq(operationRuns.userId, userId));
await this.onWrite?.();
}
}
@@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentOperationRepository,
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentHostRepository,
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentOperationRepository().anonymizeUser(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -0,0 +1,760 @@
import express, { type Request, type Response } from "express";
import type {
ResourceListResult,
SignalResult,
} from "@termix-ssh/plugin-sdk/operations";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { createCurrentOperationRepository } from "../repositories/factory.js";
import type { OperationRunRecord } from "../repositories/operation-repository.js";
import { buildCatalog } from "../../operations/catalog.js";
import {
cancelOperationRun,
decideOperationApproval,
OperationError,
runOperation,
} from "../../operations/gate.js";
import { getAction } from "../../operations/registry.js";
import {
flattenResourceRows,
readAcrossHosts,
} from "../../operations/resources.js";
import type { ActorOrigin } from "../../plugins/actor.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const permissions = PermissionManager.getInstance();
/** Sees every run on the server, not only their own. */
export const VIEW_ALL_RUNS = "admin.operations.view";
type OperationsRequest = Request & {
userId: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
actingAdminUserId?: string;
};
function originOf(req: OperationsRequest): ActorOrigin {
return { kind: req.apiKeyId ? "api" : "ui" };
}
function sendError(res: Response, error: unknown, operation: string): void {
if (error instanceof OperationError) {
res.status(error.status).json({
error: error.message,
code: error.code,
details: error.details,
});
return;
}
databaseLogger.error("Operations route failed", error, { operation });
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
}
function parseJson(text: string | null): unknown {
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Never includes the sealed input. */
function publicRun(run: OperationRunRecord) {
return {
id: run.id,
actionId: run.actionId,
pluginId: run.pluginId,
pluginVersion: run.pluginVersion,
userId: run.userId,
username: run.username,
origin: parseJson(run.origin),
hostId: run.hostId,
hostName: run.hostName,
batchId: run.batchId,
dryRun: run.dryRun,
risk: run.risk,
status: run.status,
summary: run.summary,
changed: run.changed,
input: parseJson(run.input),
output: parseJson(run.output),
undoAvailable: run.undoInput !== null,
error: run.error,
errorCode: run.errorCode,
undoOf: run.undoOf,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
async function canSeeRun(
run: OperationRunRecord,
userId: string,
): Promise<boolean> {
if (run.userId === userId) return true;
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
if (run.hostId === null) return false;
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
return access.hasAccess;
}
function hostIdsFrom(value: unknown): number[] | undefined {
if (typeof value !== "string" || value === "") return undefined;
const ids = value
.split(",")
.map(Number)
.filter((id) => Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function limitFrom(value: unknown): number | undefined {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
}
/**
* @openapi
* /operations/catalog:
* get:
* summary: List what this user can do
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
* tags:
* - Operations
* parameters:
* - in: query
* name: lang
* schema:
* type: string
* - in: query
* name: surface
* schema:
* type: string
* enum: [assistant, mcp, palette, automations, mobile, cli]
* responses:
* 200:
* description: The catalog.
*/
router.get("/catalog", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
res.json(
await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
surface:
typeof req.query.surface === "string"
? (req.query.surface as never)
: undefined,
}),
);
} catch (error) {
sendError(res, error, "operations_catalog");
}
});
/**
* @openapi
* /operations/catalog/{id}:
* get:
* summary: Describe one action
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The action.
* 404:
* description: No such action, or the caller may not use it.
*/
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
const action = catalog.actions.find((entry) => entry.id === req.params.id);
if (!action) {
res.status(404).json({ error: "Action not found" });
return;
}
res.json(action);
} catch (error) {
sendError(res, error, "operations_catalog_action");
}
});
/**
* @openapi
* /operations/runs:
* get:
* summary: List runs
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: actionId
* schema:
* type: string
* - in: query
* name: status
* schema:
* type: string
* - in: query
* name: batchId
* schema:
* type: string
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Runs, newest first.
*/
router.get("/runs", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const hostId = limitFrom(req.query.hostId);
let managed = false;
if (!viewAll && hostId !== undefined) {
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
.hasAccess;
}
const runs = await createCurrentOperationRepository().listRuns(
{
userId: viewAll || managed ? undefined : userId,
hostId,
actionId:
typeof req.query.actionId === "string"
? req.query.actionId
: undefined,
status:
typeof req.query.status === "string" ? req.query.status : undefined,
batchId:
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
res.json({ runs: runs.map(publicRun) });
} catch (error) {
sendError(res, error, "operations_runs");
}
});
/**
* @openapi
* /operations/runs/{id}:
* get:
* summary: Get a run
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The run.
* 404:
* description: Not found, or not visible to the caller.
*/
router.get("/runs/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json(publicRun(run));
} catch (error) {
sendError(res, error, "operations_run");
}
});
/**
* @openapi
* /operations/runs/{id}/cancel:
* post:
* summary: Cancel a run
* description: Stops a running action, or withdraws one waiting for approval.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Whether anything was cancelled.
* 404:
* description: Not found, or not visible to the caller.
*/
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json({ cancelled: await cancelOperationRun(run) });
} catch (error) {
sendError(res, error, "operations_cancel");
}
});
/**
* @openapi
* /operations/runs/{id}/undo:
* post:
* summary: Undo a run
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The new run.
* 404:
* description: Not found, or nothing to undo.
*/
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
if (
!run ||
!undo ||
!run.undoInput ||
!(await canSeeRun(run, authReq.userId))
) {
res.status(404).json({ error: "Nothing to undo" });
return;
}
res.json(
await runOperation({
actionId: undo,
hostId: run.hostId ?? undefined,
input: parseJson(run.undoInput),
confirmed: req.body?.confirmed === true,
undoOf: run.id,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_undo");
}
});
/**
* @openapi
* /operations/approvals:
* get:
* summary: List waiting approvals
* description: Requests the caller could decide, with the exact input that would run.
* tags:
* - Operations
* responses:
* 200:
* description: Waiting approvals.
*/
router.get("/approvals", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const repository = createCurrentOperationRepository();
const approvals = [];
for (const approval of await repository.listPendingApprovals()) {
const run = await repository.findRun(approval.runId);
if (run && (await canSeeRun(run, userId))) {
approvals.push({ ...approval, run: publicRun(run) });
}
}
res.json({ approvals });
} catch (error) {
sendError(res, error, "operations_approvals");
}
});
/**
* @openapi
* /operations/approvals/{id}:
* post:
* summary: Approve or reject a request
* description: One shot. What runs is the sealed request, never anything sent here.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [approve]
* properties:
* approve:
* type: boolean
* comment:
* type: string
* responses:
* 200:
* description: The run's outcome.
* 403:
* description: The caller may not decide this.
* 409:
* description: Already decided, expired, or the host changed.
*/
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
if (typeof req.body?.approve !== "boolean") {
res.status(400).json({ error: "approve must be true or false" });
return;
}
// A key could be handed to a script, so a person decides from a session.
if (authReq.apiKeyScopes) {
res.status(403).json({ error: "This API key may not approve" });
return;
}
try {
res.json(
await decideOperationApproval({
approvalId: String(req.params.id),
approverId: authReq.userId,
approve: req.body.approve,
comment:
typeof req.body.comment === "string" ? req.body.comment : undefined,
impersonated: !!authReq.actingAdminUserId,
}),
);
} catch (error) {
sendError(res, error, "operations_approve");
}
});
/**
* @openapi
* /operations/events:
* get:
* summary: List declared events
* tags:
* - Operations
* responses:
* 200:
* description: Declared events from running plugins, plus core's run events.
*/
router.get("/events", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
res.json({ events: catalog.events });
} catch (error) {
sendError(res, error, "operations_events");
}
});
/**
* @openapi
* /operations/timeline:
* get:
* summary: Recent events
* description: Events the caller may see, newest first. Events tied to a host need access to it.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Events.
*/
router.get("/timeline", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const rows = await createCurrentOperationRepository().listEvents(
{
hostId: limitFrom(req.query.hostId),
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const access = new Map<number, boolean>();
const events = [];
for (const row of rows) {
if (row.hostId === null) {
if (viewAll) events.push(row);
continue;
}
let allowed = access.get(row.hostId);
if (allowed === undefined) {
allowed = (
await permissions.canAccessHost(userId, row.hostId, "connect")
).hasAccess;
access.set(row.hostId, allowed);
}
if (allowed) events.push(row);
}
res.json({
events: events.map((event) => ({
...event,
payload: parseJson(event.payload),
})),
});
} catch (error) {
sendError(res, error, "operations_timeline");
}
});
/**
* @openapi
* /operations/signals:
* get:
* summary: Read status tiles
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: ids
* description: Comma separated signal ids. Leave out for all.
* schema:
* type: string
* responses:
* 200:
* description: One entry per signal with per host results.
*/
router.get("/signals", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const wanted =
typeof req.query.ids === "string" && req.query.ids
? new Set(req.query.ids.split(","))
: null;
const hostIds = hostIdsFrom(req.query.hostIds);
const signals = [];
for (const signal of catalog.signals) {
if (wanted && !wanted.has(signal.id)) continue;
if (signal.scope === "server") {
const outcome = await runOperation({
actionId: signal.read,
internal: true,
userId: authReq.userId,
origin: originOf(authReq),
}).catch(() => null);
signals.push({
id: signal.id,
hosts: [],
result: outcome?.data as SignalResult | undefined,
});
continue;
}
signals.push({
id: signal.id,
hosts: await readAcrossHosts<SignalResult>({
userId: authReq.userId,
actionId: signal.read,
hostIds,
origin: originOf(authReq),
}),
});
}
res.json({ signals });
} catch (error) {
sendError(res, error, "operations_signals");
}
});
/**
* @openapi
* /operations/resources/{id}:
* get:
* summary: Read one kind of thing across hosts
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: Rows and per host state.
* 404:
* description: No such resource, or the caller may not read it.
*/
router.get("/resources/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const resource = catalog.resources.find(
(entry) => entry.id === req.params.id,
);
if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
}
const reads = await readAcrossHosts<ResourceListResult>({
userId: authReq.userId,
actionId: resource.list,
hostIds: hostIdsFrom(req.query.hostIds),
refresh: req.query.refresh === "true",
origin: originOf(authReq),
});
res.json(flattenResourceRows(reads, resource.key));
} catch (error) {
sendError(res, error, "operations_resources");
}
});
/**
* @openapi
* /operations/{id}/run:
* post:
* summary: Run an action
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* resourceKey:
* type: string
* input:
* type: object
* dryRun:
* type: boolean
* confirmed:
* type: boolean
* idempotencyKey:
* type: string
* wait:
* type: boolean
* responses:
* 200:
* description: The outcome, or a run waiting for approval.
* 400:
* description: Invalid input.
* 403:
* description: Not allowed.
* 409:
* description: Needs a confirm, or safe mode is on.
*/
router.post("/:id/run", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
const body = (req.body ?? {}) as Record<string, unknown>;
try {
res.json(
await runOperation({
actionId: String(req.params.id),
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
resourceKey:
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
input: body.input,
dryRun: body.dryRun === true,
confirmed: body.confirmed === true,
idempotencyKey:
typeof body.idempotencyKey === "string"
? body.idempotencyKey.slice(0, 128)
: undefined,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_run_action");
}
});
export default router;
+129
View File
@@ -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);
}
+251
View File
@@ -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();
}
+8
View File
@@ -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;
+155
View File
@@ -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();
}
+891
View File
@@ -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();
}
+68
View File
@@ -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 };
+44
View File
@@ -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);
}
+90
View File
@@ -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;
}
+47
View File
@@ -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;
}
+84
View File
@@ -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();
}
+231
View File
@@ -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();
}
+49 -2
View File
@@ -20,7 +20,28 @@
import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
/** How a call reached core, for run history and approval rules. */
export interface ActorOrigin {
kind: "ui" | "api" | "plugin";
/** Which plugin asked, set by core from the ctx that made the call. */
pluginId?: string;
/** What the plugin was doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
/** Follows a call through plugin work, so nothing loses who started it. */
export interface ActorTrail {
origin?: ActorOrigin;
apiKeyId?: string;
/** Null means a key with full access. */
apiKeyScopes?: string[] | null;
/** The operation run this work belongs to. */
runId?: string;
/** How many operations or events led here. */
depth?: number;
}
interface ActorStore extends ActorTrail {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service";
@@ -35,8 +56,34 @@ export function runAsActor<T>(
source: ActorStore["source"],
fn: () => T,
sessionId?: string,
trail?: ActorTrail,
): T {
return storage.run({ userId, source, sessionId }, fn);
const parent = storage.getStore();
return storage.run(
{
origin: parent?.origin,
apiKeyId: parent?.apiKeyId,
apiKeyScopes: parent?.apiKeyScopes,
runId: parent?.runId,
depth: parent?.depth,
...trail,
userId,
source,
sessionId,
},
fn,
);
}
export function getActorTrail(): ActorTrail {
const store = storage.getStore();
return {
origin: store?.origin,
apiKeyId: store?.apiKeyId,
apiKeyScopes: store?.apiKeyScopes,
runId: store?.runId,
depth: store?.depth,
};
}
export function getActorSessionId(): string | undefined {
+195
View File
@@ -0,0 +1,195 @@
/**
* ctx.operations: declaring what a plugin can do, and asking for another
* plugin's action to run.
*
* invoke acts for the current user, so on its own it would let any plugin spend
* that user's full authority. It needs the capability, the action named in the
* manifest's invokes list, and the origin always names the calling plugin, set
* here from the closure rather than from anything the plugin passes.
*/
import type {
PluginOperations,
PluginOperationInvokeOptions,
} from "@termix-ssh/plugin-sdk/backend";
import {
invokeAllowed,
type OperationAction,
} from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "./manifest.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor, getActorTrail, type ActorOrigin } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
registerAction,
revokeAction,
type OperationHandler,
} from "../operations/registry.js";
import { provideApprovalPolicy } from "../operations/approvals.js";
import {
emitOperationEvent,
subscribeOperationEvents,
} from "../operations/events.js";
import {
drainPlugin,
OperationError,
runOperation,
} from "../operations/gate.js";
import { buildCatalog } from "../operations/catalog.js";
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
}
export function createPluginOperations({
manifest,
bag,
}: Deps): PluginOperations {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const contribution = manifest.contributes?.operations;
let drainRegistered = false;
const declaredAction = (id: string): OperationAction => {
const action = contribution?.actions?.find((entry) => entry.id === id);
if (!action) {
throw new Error(
`Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`,
);
}
return action;
};
const actingUser = (): string => {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.operations needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
};
return {
register: (id, handler) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const registration = registerAction(
pluginId,
declaredAction(id),
handler as OperationHandler,
);
bag.add(() => void revokeAction(registration), `action "${id}"`);
// Added once and after the actions, so disposal, which runs newest
// first, drains before anything the handlers use is torn down.
if (!drainRegistered) {
drainRegistered = true;
bag.add(() => drainPlugin(pluginId), "operations drain");
}
},
// A plugin's own route running its own action, so a button press lands in
// the same history. The caller stays the origin, not this plugin.
run: async (actionId, options = {}) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
declaredAction(actionId);
const trail = getActorTrail();
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin: trail.origin ?? { kind: "plugin", pluginId },
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
invoke: async (actionId, options = {}) => {
await assertCapability(pluginId, "operations:invoke", declared);
if (!invokeAllowed(contribution?.invokes, actionId)) {
throw new OperationError(
"FORBIDDEN",
`${pluginId} did not declare that it runs ${actionId}`,
);
}
const trail = getActorTrail();
const origin: ActorOrigin = {
kind: "plugin",
pluginId,
via: (options as PluginOperationInvokeOptions).via,
};
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin,
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
list: async (options) => {
await assertCapability(pluginId, "operations:invoke", declared);
return buildCatalog({
userId: actingUser(),
lang: options?.lang,
surface: options?.surface,
});
},
emit: async (eventId, payload, options) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const event = contribution?.events?.find((entry) => entry.id === eventId);
if (!event) {
throw new Error(
`Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`,
);
}
await emitOperationEvent({
eventId,
pluginId,
hostId: options?.hostId,
severity: event.severity,
payload,
});
},
onEvent: (subscription) => {
if (!declared.includes("operations:invoke")) {
throw capabilityRefused(pluginId, "operations:invoke");
}
const remove = subscribeOperationEvents(pluginId, subscription);
bag.add(remove, `events "${subscription.pattern}"`);
return remove;
},
approvals: {
provide: (provider) => {
if (!declared.includes("operations:approve")) {
throw capabilityRefused(pluginId, "operations:approve");
}
const remove = provideApprovalPolicy(pluginId, manifest, provider);
bag.add(remove, "approval rules");
return remove;
},
},
};
}
+3
View File
@@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginOperations } from "./ctx-operations.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
@@ -976,6 +977,8 @@ export function createPluginContext(
void reportRuntimeError(pluginId, error);
}),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
operations: createPluginOperations({ manifest, bag: handle.bag }),
notify: createPluginNotify({
manifest,
audit: auditCall,
+8 -2
View File
@@ -354,15 +354,21 @@ export function createPluginRouter({
// After auth, so the actor is the user core authenticated rather than
// whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => {
const { userId, sessionId } = req as Request & {
const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & {
userId?: string;
sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
};
if (!userId) {
next();
return;
}
runAsActor(userId, "request", () => next(), sessionId);
runAsActor(userId, "request", () => next(), sessionId, {
origin: { kind: apiKeyId ? "api" : "ui" },
apiKeyId,
apiKeyScopes,
});
});
outer.use(inner);
+3
View File
@@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Last, so a plugin's activate() sees a fully wired server. A plugin that
// fails to load must not stop the backend, so this never rejects.
try {
const { startOperationsMaintenance } =
await import("./operations/maintenance.js");
await startOperationsMaintenance();
const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins({ freshInstall });
if (loaded.length > 0) {
@@ -0,0 +1,277 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
userId: "user-1",
apiKeyId: undefined as string | undefined,
apiKeyScopes: undefined as string[] | null | undefined,
actingAdminUserId: undefined as string | undefined,
viewAll: false,
hostAccess: false,
runs: new Map<string, Record<string, unknown>>(),
runOperation: vi.fn(),
decide: vi.fn(),
cancel: vi.fn(async () => true),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown>,
_res: unknown,
next: () => void,
): void => {
req.userId = state.userId;
req.apiKeyId = state.apiKeyId;
req.apiKeyScopes = state.apiKeyScopes;
req.actingAdminUserId = state.actingAdminUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.viewAll,
canAccessHost: async () => ({ hasAccess: state.hostAccess }),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentOperationRepository: () => ({
findRun: async (id: string) => state.runs.get(id) ?? null,
listRuns: async (filters: { userId?: string }) =>
[...state.runs.values()].filter(
(run) => !filters.userId || run.userId === filters.userId,
),
listPendingApprovals: async () => [],
listEvents: async () => [],
}),
}));
vi.mock("../../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({
actions: [{ id: "demo.restart" }],
events: [],
resources: [],
signals: [],
})),
}));
vi.mock("../../../operations/resources.js", () => ({
flattenResourceRows: vi.fn(),
readAcrossHosts: vi.fn(async () => []),
}));
vi.mock("../../../operations/gate.js", () => {
class OperationError extends Error {
status: number;
constructor(
readonly code: string,
message: string,
status = 403,
) {
super(message);
this.status = status;
}
}
return {
OperationError,
runOperation: state.runOperation,
decideOperationApproval: state.decide,
cancelOperationRun: state.cancel,
};
});
const { default: router } =
await import("../../../database/routes/operations-routes.js");
const { OperationError } = await import("../../../operations/gate.js");
const app = express();
app.use(express.json());
app.use("/operations", router);
const run = (overrides: Record<string, unknown> = {}) => ({
id: "run-1",
actionId: "demo.restart",
pluginId: "demo",
pluginVersion: "1.0.0",
userId: "user-1",
username: "name",
origin: '{"kind":"ui"}',
hostId: 1,
hostName: "web",
batchId: null,
dryRun: false,
risk: "change",
status: "success",
summary: "done",
changed: true,
input: '{"token":"[redacted]"}',
sealedInput: "sealed:secret",
output: null,
undoInput: null,
error: null,
errorCode: null,
undoOf: null,
createdAt: "2026-01-01",
startedAt: null,
finishedAt: null,
...overrides,
});
beforeEach(() => {
state.userId = "user-1";
state.apiKeyId = undefined;
state.apiKeyScopes = undefined;
state.actingAdminUserId = undefined;
state.viewAll = false;
state.hostAccess = false;
state.runs.clear();
state.runOperation.mockReset();
state.runOperation.mockResolvedValue({ runId: "r", status: "success" });
state.decide.mockReset();
state.decide.mockResolvedValue({ runId: "r", status: "success" });
});
describe("POST /operations/:id/run", () => {
it("passes a session as a ui origin", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ hostId: 1, input: { name: "x" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "demo.restart",
hostId: 1,
userId: "user-1",
origin: { kind: "ui" },
}),
);
});
it("passes a key as an api origin with its scopes", async () => {
state.apiKeyId = "key";
state.apiKeyScopes = ["operations:read"];
await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: { kind: "api" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not take the user or origin from the body", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ userId: "admin", origin: { kind: "plugin", via: "automation" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }),
);
});
it("sends a refusal's status and code", async () => {
state.runOperation.mockRejectedValue(
new (
OperationError as never as new (code: string, message: string) => Error
)("FORBIDDEN", "No"),
);
const response = await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(403);
expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" });
});
});
describe("runs", () => {
it("never returns the sealed input", async () => {
state.runs.set("run-1", run());
const response = await request(app)
.get("/operations/runs/run-1")
.expect(200);
expect(JSON.stringify(response.body)).not.toContain("secret");
});
it("hides another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).get("/operations/runs/run-1").expect(404);
});
it("shows a run to someone who manages its host", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
state.hostAccess = true;
await request(app).get("/operations/runs/run-1").expect(200);
});
it("lists only a user's own runs", async () => {
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([
"a",
]);
});
it("lists everything with the view permission", async () => {
state.viewAll = true;
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs).toHaveLength(2);
});
it("does not cancel another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).post("/operations/runs/run-1/cancel").expect(404);
expect(state.cancel).not.toHaveBeenCalled();
});
});
describe("POST /operations/approvals/:id", () => {
it("needs a clear answer", async () => {
await request(app)
.post("/operations/approvals/a")
.send({ approve: "yes" })
.expect(400);
expect(state.decide).not.toHaveBeenCalled();
});
it("refuses a scoped key", async () => {
state.apiKeyScopes = ["operations:run"];
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(403);
expect(state.decide).not.toHaveBeenCalled();
});
it("marks an impersonated request", async () => {
state.actingAdminUserId = "admin";
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(200);
expect(state.decide).toHaveBeenCalledWith(
expect.objectContaining({ impersonated: true }),
);
});
});
describe("GET /operations/catalog", () => {
it("answers", async () => {
const response = await request(app).get("/operations/catalog").expect(200);
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
});
});
+694
View File
@@ -0,0 +1,694 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OperationAction } from "@termix-ssh/plugin-sdk/operations";
const state = vi.hoisted(() => ({
permissions: new Set<string>(),
hostLevels: new Map<number, string>(),
plugin: {
state: "active",
source: "bundled",
signedTier: undefined as string | undefined,
manifest: {
version: "1.0.0",
capabilities: ["operations:provide"],
},
},
capability: true,
safeMode: false,
host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as {
name: string;
updatedAt: string;
tags: string[];
},
runs: new Map<string, Record<string, unknown>>(),
approvals: new Map<string, Record<string, unknown>>(),
audits: [] as Record<string, unknown>[],
events: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (text: string) => `sealed:${text}`,
decryptSystemSecret: async (text: string) => text.slice("sealed:".length),
isSystemEncrypted: (text: string) => text.startsWith("sealed:"),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (userId: string, permission: string) =>
state.permissions.has(`${userId}:${permission}`),
canAccessHost: async (userId: string, hostId: number, level: string) => {
const held = state.hostLevels.get(hostId);
const rank = ["connect", "view", "edit", "manage"];
return {
hasAccess:
held !== undefined && rank.indexOf(held) >= rank.indexOf(level),
};
},
}),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
hasCapability: async () => state.capability,
}));
vi.mock("../../plugins/safe-mode.js", () => ({
safeModeOn: () => state.safeMode,
}));
vi.mock("../../plugins/manifest.js", () => ({
qualifyPermission: (pluginId: string, name: string) =>
name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`,
}));
vi.mock("../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { get: () => state.plugin } }),
}));
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: { get: () => null },
}));
vi.mock("../../plugins/ctx-probe.js", () => ({
loadProbeTargets: async () => [],
}));
vi.mock("../../operations/hosts.js", () => ({
loadOperationHost: async (id: number) => ({
id,
...state.host,
os: null,
settings: {},
}),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({ username: `name-${id}` }),
}),
createCurrentOperationRepository: () => ({
insertRun: async (run: Record<string, unknown>) => {
state.runs.set(run.id as string, { ...run });
},
findRun: async (id: string) => state.runs.get(id) ?? null,
findByIdempotencyKey: async (userId: string, key: string) =>
[...state.runs.values()].find(
(run) => run.userId === userId && run.idempotencyKey === key,
) ?? null,
updateRunFrom: async (
id: string,
from: string,
patch: Record<string, unknown>,
) => {
const run = state.runs.get(id);
if (!run || run.status !== from) return false;
Object.assign(run, patch);
return true;
},
insertApproval: async (row: Record<string, unknown>) => {
state.approvals.set(row.id as string, { ...row });
},
findApproval: async (id: string) => state.approvals.get(id) ?? null,
findApprovalForRun: async (runId: string) =>
[...state.approvals.values()].find((row) => row.runId === runId) ?? null,
decideApproval: async (id: string, status: string) => {
const row = state.approvals.get(id);
if (!row || row.status !== "pending") return false;
row.status = status;
return true;
},
insertEvent: async (event: Record<string, unknown>) => {
state.events.push(event);
},
}),
}));
const { registerAction, clearOperationRegistry } =
await import("../../operations/registry.js");
const { provideApprovalPolicy, clearApprovalPolicies } =
await import("../../operations/approvals.js");
const {
cancelOperationRun,
clearOperationGate,
decideOperationApproval,
drainPlugin,
OperationError,
OperationNeedsInteraction,
runOperation,
} = await import("../../operations/gate.js");
const USER = "user-1";
const APPROVER = "user-2";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
inputs: [
{ key: "name", type: "string", required: true },
{ key: "token", type: "secret" },
],
dryRun: "native",
...overrides,
});
function register(
overrides: Partial<OperationAction> = {},
run = vi.fn(async () => ({ summary: "done", changed: true })),
) {
registerAction("demo", action(overrides), { run });
return run;
}
const manifestForPolicy = {
capabilities: ["operations:approve"],
} as never;
const base = {
actionId: "demo.restart",
hostId: 1,
input: { name: "nginx" },
userId: USER,
origin: { kind: "ui" } as const,
};
async function refused(request: Parameters<typeof runOperation>[0]) {
try {
await runOperation(request);
} catch (error) {
expect(error).toBeInstanceOf(OperationError);
return (error as InstanceType<typeof OperationError>).code;
}
throw new Error("Expected a refusal");
}
beforeEach(() => {
state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]);
state.hostLevels = new Map([[1, "edit"]]);
state.plugin.state = "active";
state.plugin.source = "bundled";
state.plugin.manifest.version = "1.0.0";
state.capability = true;
state.safeMode = false;
state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] };
state.runs.clear();
state.approvals.clear();
state.audits.length = 0;
state.events.length = 0;
});
afterEach(() => {
clearOperationRegistry();
clearApprovalPolicies();
clearOperationGate();
});
describe("refusals", () => {
it("does not run an unknown action", async () => {
expect(await refused(base)).toBe("NOT_FOUND");
});
it("does not run in safe mode", async () => {
register();
state.safeMode = true;
expect(await refused(base)).toBe("SAFE_MODE");
});
it("does not run for a plugin that stopped", async () => {
register();
state.plugin.state = "disabled";
expect(await refused(base)).toBe("UNAVAILABLE");
});
it("rechecks the grant at use", async () => {
const run = register();
state.capability = false;
expect(await refused(base)).toBe("UNAVAILABLE");
expect(run).not.toHaveBeenCalled();
});
it("needs the role permission", async () => {
const run = register();
state.permissions.clear();
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("needs the declared host level", async () => {
const run = register();
state.hostLevels.set(1, "connect");
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("refuses bad input before anything runs", async () => {
const run = register();
expect(await refused({ ...base, input: { nope: 1 } })).toBe(
"INVALID_INPUT",
);
expect(run).not.toHaveBeenCalled();
});
it("needs a host for a host action", async () => {
register();
expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT");
});
it("needs a confirm for destructive actions", async () => {
register({ risk: "destructive" });
expect(await refused(base)).toBe("NEEDS_CONFIRM");
expect((await runOperation({ ...base, confirmed: true })).status).toBe(
"success",
);
});
it("stops long chains", async () => {
register();
expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP");
});
});
describe("api key scopes", () => {
it("lets a read key run reads only", async () => {
register();
register({ id: "demo.read", risk: "read", hostAccess: "connect" });
expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe(
"SCOPE",
);
const read = await runOperation({
...base,
actionId: "demo.read",
apiKeyScopes: ["operations:read"],
});
expect(read.status).toBe("success");
});
it("treats no scopes as full access", async () => {
register();
const outcome = await runOperation({ ...base, apiKeyScopes: null });
expect(outcome.status).toBe("success");
});
});
describe("runs", () => {
it("records a run and never stores a secret", async () => {
const run = register();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
const row = state.runs.get(outcome.runId!)!;
expect(row.status).toBe("success");
expect(JSON.stringify(row)).not.toContain("hunter2");
expect(JSON.stringify(state.audits)).not.toContain("hunter2");
expect(JSON.stringify(state.events)).not.toContain("hunter2");
});
it("keeps failures in the run, not thrown", async () => {
register(
{},
vi.fn(async () => {
throw new Error("no such unit");
}),
);
const outcome = await runOperation(base);
expect(outcome).toMatchObject({ status: "failed", error: "no such unit" });
expect(state.runs.get(outcome.runId!)!.status).toBe("failed");
});
it("reports hosts that need a person", async () => {
register(
{},
vi.fn(async () => {
throw new OperationNeedsInteraction("Sign in first");
}),
);
const outcome = await runOperation(base);
expect(outcome.status).toBe("needs_interaction");
});
it("passes dry run to a native handler", async () => {
const run = register();
await runOperation({ ...base, dryRun: true });
expect(run.mock.calls[0]).toBeDefined();
expect(
(run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1]
.dryRun,
).toBe(true);
});
it("never calls run for a describe dry run", async () => {
const run = vi.fn(async () => ({ summary: "x" }));
registerAction("demo", action({ dryRun: "describe" }), {
run,
describe: () => "Would restart nginx",
});
const outcome = await runOperation({ ...base, dryRun: true });
expect(outcome.summary).toBe("Would restart nginx");
expect(run).not.toHaveBeenCalled();
});
it("drops sensitive outputs", async () => {
register(
{ outputs: [{ key: "conf", type: "textarea", sensitive: true }] },
vi.fn(async () => ({
summary: "ok",
data: { conf: "private key" },
})),
);
const outcome = await runOperation(base);
expect(JSON.stringify(outcome)).not.toContain("private key");
expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain(
"private key",
);
});
it("writes no row for a background read", async () => {
register({ risk: "read", hostAccess: "connect" });
const outcome = await runOperation({ ...base, internal: true });
expect(outcome.runId).toBeNull();
expect(state.runs.size).toBe(0);
});
it("does not run twice for one idempotency key", async () => {
const run = register();
const first = await runOperation({ ...base, idempotencyKey: "abc" });
const second = await runOperation({ ...base, idempotencyKey: "abc" });
expect(second.runId).toBe(first.runId);
expect(run).toHaveBeenCalledTimes(1);
});
it("emits a run event for a change", async () => {
register();
await runOperation(base);
await vi.waitFor(() =>
expect(state.events[0]).toMatchObject({
eventId: "operation.succeeded",
}),
);
});
it("stops a run on cancel", async () => {
let started = () => {};
const ready = new Promise<void>((resolve) => (started = resolve));
register(
{},
vi.fn(
(_input, context: { signal: AbortSignal }) =>
new Promise((_, reject) => {
started();
context.signal.addEventListener("abort", () =>
reject(new Error("stopped")),
);
}),
) as never,
);
const running = runOperation(base);
await ready;
const row = [...state.runs.values()][0]!;
expect(await cancelOperationRun(row as never)).toBe(true);
expect((await running).status).toBe("failed");
});
});
describe("policy", () => {
it("keeps AI and MCP away from changes with no rule", async () => {
register();
for (const via of ["assistant", "mcp"]) {
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via },
}),
).toBe("DENIED");
}
});
it("refuses a plugin with no stated purpose", async () => {
register();
expect(
await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }),
).toBe("DENIED");
});
it("lets a person's automation go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "automation" },
});
expect(outcome.status).toBe("success");
});
it("treats a community read as a change", async () => {
state.plugin.source = "user";
state.plugin.signedTier = "community";
register({ risk: "read", hostAccess: "connect" });
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
}),
).toBe("DENIED");
state.plugin.signedTier = undefined;
});
it("denies when a provider throws", async () => {
register();
provideApprovalPolicy("rules", manifestForPolicy, () => {
throw new Error("boom");
});
expect(await refused(base)).toBe("DENIED");
});
it("takes the strictest answer", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({
decision: "allow",
}));
provideApprovalPolicy("b", manifestForPolicy, () => ({
decision: "deny",
reason: "No",
}));
expect(await refused(base)).toBe("DENIED");
});
it("audits a denial", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" }));
await refused(base);
expect(state.audits[0]).toMatchObject({
action: "operation_denied",
success: false,
});
});
});
describe("approvals", () => {
const needApproval = () =>
provideApprovalPolicy("rules", manifestForPolicy, () => ({
decision: "approve",
}));
async function pending(run = register()) {
needApproval();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("pending_approval");
expect(run).not.toHaveBeenCalled();
return outcome;
}
const decide = (approvalId: string, approverId = APPROVER) =>
decideOperationApproval({ approvalId, approverId, approve: true });
it("runs the sealed input once approved, then wipes it", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
const row = state.runs.get(runId!)!;
expect(row.input).not.toContain("hunter2");
expect(row.sealedInput).toBeTruthy();
const outcome = await decide(approvalId!);
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
it("is one shot", async () => {
const run = register();
const { approvalId } = await pending(run);
await decide(approvalId!);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "CONFLICT",
});
expect(run).toHaveBeenCalledTimes(1);
});
it("does not let a person approve their own request", async () => {
const { approvalId } = await pending();
await expect(decide(approvalId!, USER)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("lets a person approve what an AI asked for", async () => {
register();
needApproval();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
});
const done = await decide(outcome.approvalId!, USER);
expect(done.status).toBe("success");
});
it("does not let someone impersonating approve", async () => {
const { approvalId } = await pending();
await expect(
decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: true,
impersonated: true,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("needs the approver to hold the action's rights", async () => {
const { approvalId } = await pending();
state.permissions.delete(`${APPROVER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rechecks the requester when it runs", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
state.permissions.delete(`${USER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
expect(run).not.toHaveBeenCalled();
expect(state.runs.get(runId!)!.status).toBe("rejected");
});
it("goes stale when the host changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.host.updatedAt = "2026-02-02";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("goes stale when the plugin changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.plugin.manifest.version = "2.0.0";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("refuses a sealed copy from another run", async () => {
const run = register();
const first = await pending(run);
const second = await runOperation(base);
state.runs.get(second.runId!)!.sealedInput = state.runs.get(
first.runId!,
)!.sealedInput;
await expect(decide(second.approvalId!)).rejects.toMatchObject({
code: "STALE",
});
expect(run).not.toHaveBeenCalled();
});
it("refuses an expired approval", async () => {
const run = register();
const { approvalId } = await pending(run);
state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("wipes the sealed input on reject", async () => {
const { approvalId, runId } = await pending();
const outcome = await decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: false,
});
expect(outcome.status).toBe("rejected");
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
});
describe("drain", () => {
it("waits for a handler that is still stopping", async () => {
let finish = () => {};
register(
{},
vi.fn(
() =>
new Promise<never>((_, reject) => {
finish = () => reject(new Error("closed"));
}),
) as never,
);
vi.useFakeTimers();
void runOperation(base).catch(() => {});
await vi.advanceTimersByTimeAsync(0);
let drained = false;
const draining = drainPlugin("demo").then(() => (drained = true));
await vi.advanceTimersByTimeAsync(5_000);
expect(drained).toBe(false);
finish();
await vi.advanceTimersByTimeAsync(0);
await draining;
vi.useRealTimers();
expect(drained).toBe(true);
});
it("stops running work and marks it interrupted", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
const running = runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await vi.advanceTimersByTimeAsync(11_000);
await draining;
await running;
vi.useRealTimers();
expect([...state.runs.values()][0]!.status).toBe("interrupted");
});
it("refuses new runs while draining", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
void runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await expect(runOperation(base)).rejects.toMatchObject({
code: "UNAVAILABLE",
});
await vi.advanceTimersByTimeAsync(11_000);
await draining;
vi.useRealTimers();
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js";
const READ = ["operations:read"];
const RUN = ["operations:run"];
describe("keyScopeAllows", () => {
it("leaves a key with no scopes alone", () => {
expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true);
expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true);
});
it("keeps a scoped key off every other route", () => {
for (const path of [
"/users/ws-ticket/api-key",
"/host/1",
"/plugin-api/docker/1/containers",
"/credentials",
]) {
expect(keyScopeAllows(RUN, "GET", path)).toBe(false);
}
});
it("lets a read key read and start runs the gate then checks", () => {
expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe(
false,
);
expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe(
false,
);
});
it("reaches a plugin's MCP route only", () => {
expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe(
true,
);
expect(
keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"),
).toBe(false);
});
it("does not match lookalike paths", () => {
expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false);
});
it("grants nothing with an empty list", () => {
expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false);
});
});
describe("parseKeyScopes", () => {
it("reads a list", () => {
expect(parseKeyScopes('["operations:read"]')).toEqual(READ);
});
it("keeps old keys at full access", () => {
expect(parseKeyScopes(null)).toBeNull();
});
it("fails closed on junk", () => {
expect(parseKeyScopes("{nope")).toEqual([]);
expect(parseKeyScopes('"x"')).toEqual([]);
});
});
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "../../plugins/manifest.js";
const gate = vi.hoisted(() => ({
runOperation: vi.fn(async () => ({ runId: "r", status: "success" })),
drainPlugin: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../operations/gate.js", () => ({
...gate,
OperationError: class extends Error {
constructor(_code: string, message: string) {
super(message);
}
},
}));
vi.mock("../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({ actions: [] })),
}));
vi.mock("../../operations/events.js", () => ({
emitOperationEvent: vi.fn(async () => {}),
subscribeOperationEvents: vi.fn(() => () => {}),
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn(
async (_id: string, capability: string, declared: string[]) => {
if (!declared.includes(capability)) throw new Error("refused");
},
),
capabilityRefused: (pluginId: string, capability: string) =>
new Error(`${pluginId} lacks ${capability}`),
}));
const { createPluginOperations } =
await import("../../plugins/ctx-operations.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { clearOperationRegistry, getAction } =
await import("../../operations/registry.js");
const { runAsActor } = await import("../../plugins/actor.js");
const action = {
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
};
function build(capabilities: string[], invokes?: string[]) {
const bag = new DisposableBag("demo");
const manifest = {
id: "demo",
capabilities,
contributes: { operations: { actions: [action], invokes } },
} as unknown as PluginManifest;
return { bag, operations: createPluginOperations({ manifest, bag }) };
}
const handler = { run: async () => ({ summary: "ok" }) };
beforeEach(() => {
clearOperationRegistry();
gate.runOperation.mockClear();
gate.drainPlugin.mockClear();
});
describe("register", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.register("demo.restart", handler)).toThrow(
"operations:provide",
);
});
it("refuses an undeclared action", () => {
const { operations } = build(["operations:provide"]);
expect(() => operations.register("demo.other", handler)).toThrow(
"not declared",
);
});
it("drains, then revokes, when the plugin stops", async () => {
const { bag, operations } = build(["operations:provide"]);
operations.register("demo.restart", handler);
expect(getAction("demo.restart")).toBeDefined();
await bag.disposeAll();
expect(gate.drainPlugin).toHaveBeenCalledWith("demo");
expect(getAction("demo.restart")).toBeUndefined();
});
it("keeps the new handler when an old disposer runs after a restart", async () => {
const first = build(["operations:provide"]);
first.operations.register("demo.restart", handler);
const second = build(["operations:provide"]);
const fresh = { run: async () => ({ summary: "new" }) };
second.operations.register("demo.restart", fresh);
await first.bag.disposeAll();
expect(getAction("demo.restart")?.handler).toBe(fresh);
});
});
describe("invoke", () => {
const asUser = <T>(fn: () => Promise<T>) =>
runAsActor("user", "request", fn, undefined, {
origin: { kind: "ui" },
apiKeyScopes: ["operations:read"],
});
it("needs the capability", async () => {
const { operations } = build([], ["*"]);
await expect(
asUser(() => operations.invoke("docker.restart")),
).rejects.toThrow("refused");
});
it("only reaches what the manifest lists", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await expect(
asUser(() => operations.invoke("firewall.allow")),
).rejects.toThrow("did not declare");
expect(gate.runOperation).not.toHaveBeenCalled();
});
it("names the plugin as origin and keeps the key's limits", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await asUser(() =>
operations.invoke("docker.restart", { via: "automation" }),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user",
origin: { kind: "plugin", pluginId: "demo", via: "automation" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not let a plugin pick its own origin", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await asUser(() =>
operations.invoke("docker.restart", {
origin: { kind: "ui" },
} as never),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }),
}),
);
});
it("needs an acting user", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await expect(operations.invoke("docker.restart")).rejects.toThrow(
"acting user",
);
});
});
describe("run", () => {
it("keeps the caller as origin for a plugin's own action", async () => {
const { operations } = build(["operations:provide"]);
await runAsActor(
"user",
"request",
() => operations.run("demo.restart", { hostId: 1 }),
undefined,
{ origin: { kind: "ui" } },
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }),
);
});
it("refuses another plugin's action", async () => {
const { operations } = build(["operations:provide"]);
await expect(
runAsActor("user", "request", () => operations.run("docker.restart")),
).rejects.toThrow("not declared");
});
});
describe("emit", () => {
it("refuses an undeclared event", async () => {
const { operations } = build(["operations:provide"]);
await expect(operations.emit("demo.nothing")).rejects.toThrow(
"not declared",
);
});
});
describe("approvals", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.approvals.provide(() => null)).toThrow(
"operations:approve",
);
});
});
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { parseManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
invokeAllowed,
matchesWhen,
onSurface,
parseOperationInput,
redactFields,
toJsonSchema,
type OperationAction,
type OperationField,
} from "@termix-ssh/plugin-sdk/operations";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.allow-port",
titleKey: "ops.allow",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
...overrides,
});
function manifest(operations: unknown, capabilities = ["operations:provide"]) {
return {
id: "demo",
name: "Demo",
version: "1.0.0",
description: "A demo.",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.11.1-0", api: "^1.13" },
capabilities,
contributes: {
permissions: [
{ name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" },
],
operations,
},
};
}
function errorsFor(operations: unknown, capabilities?: string[]): string[] {
return parseManifest(manifest(operations, capabilities)).errors ?? [];
}
describe("contributes.operations", () => {
it("accepts a full declaration", () => {
const errors = errorsFor({
actions: [
action({
check: "demo.port-allowed",
inputs: [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{ key: "name", type: "resource", resource: "demo.rule" },
],
}),
action({
id: "demo.port-allowed",
risk: "read",
hostAccess: "connect",
}),
action({
id: "demo.list-rules",
risk: "read",
hostAccess: "connect",
}),
],
events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.list-rules",
key: "id",
name: "name",
},
],
signals: [
{
id: "demo.rules",
titleKey: "s",
read: "demo.port-allowed",
scope: "host",
kind: "count",
fix: "demo.allow-port",
},
],
});
expect(errors).toEqual([]);
});
it("needs the capability", () => {
expect(errorsFor({ actions: [action()] }, [])).toContainEqual(
expect.stringContaining("operations:provide"),
);
});
it("refuses an id outside the plugin", () => {
expect(
errorsFor({ actions: [action({ id: "docker.stop" })] }),
).toContainEqual(expect.stringContaining('must start with "demo."'));
});
it("refuses a destructive action a connect level user could run", () => {
expect(
errorsFor({
actions: [action({ risk: "destructive", hostAccess: "connect" })],
}),
).toContainEqual(expect.stringContaining("edit or manage"));
});
it("lets a change declare its own level", () => {
expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual(
[],
);
});
it("refuses an undeclared permission", () => {
expect(
errorsFor({ actions: [action({ permission: "nope" })] }),
).toContainEqual(expect.stringContaining("is not declared"));
});
it("refuses a check that is not a read", () => {
expect(
errorsFor({
actions: [
action({ check: "demo.other" }),
action({ id: "demo.other" }),
],
}),
).toContainEqual(expect.stringContaining("must be a read action"));
});
it("refuses references that go nowhere", () => {
const errors = errorsFor({
actions: [action({ undo: "demo.gone" })],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.gone",
key: "id",
name: "n",
},
],
});
expect(errors.length).toBeGreaterThanOrEqual(2);
});
it("refuses unknown fields", () => {
expect(
errorsFor({ actions: [{ ...action(), extra: true }] }),
).toContainEqual(expect.stringContaining('Unknown field "extra"'));
});
it("refuses a default on a secret", () => {
expect(
errorsFor({
actions: [
action({
inputs: [{ key: "token", type: "secret", default: "abc" }],
}),
],
}),
).toContainEqual(expect.stringContaining("not allowed on a secret"));
});
it("needs operations:invoke for invokes", () => {
expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual(
expect.stringContaining("operations:invoke"),
);
});
});
describe("parseOperationInput", () => {
const fields: OperationField[] = [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{
key: "proto",
type: "select",
default: "tcp",
options: [
{ value: "tcp", labelKey: "t" },
{ value: "udp", labelKey: "u" },
],
},
{ key: "name", type: "string", pattern: "[a-z]+" },
{ key: "data", type: "json" },
];
it("coerces strings and fills defaults", () => {
expect(parseOperationInput(fields, { port: "443" })).toEqual({
values: { port: 443, proto: "tcp" },
errors: [],
});
});
it("refuses keys no field declares", () => {
expect(
parseOperationInput(fields, { port: 1, sudo: true }).errors,
).toContainEqual(expect.stringContaining('"sudo"'));
});
it("needs required fields", () => {
expect(parseOperationInput(fields, {}).errors).toEqual([
'"port" is required',
]);
});
it("checks bounds, options and the whole pattern", () => {
expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, proto: "icmp" }).errors,
).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors,
).toEqual(['"name" has the wrong format']);
});
it("parses json strings", () => {
expect(
parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data,
).toEqual({ a: 1 });
});
it("refuses a redacted echo for a secret", () => {
const secret: OperationField[] = [{ key: "token", type: "secret" }];
expect(
parseOperationInput(secret, { token: { set: true } }).errors,
).toHaveLength(1);
});
it("refuses input that is not an object", () => {
expect(parseOperationInput(fields, "nope").errors).toHaveLength(1);
});
});
describe("helpers", () => {
it("redacts secrets and sensitive outputs", () => {
expect(
redactFields(
[
{ key: "token", type: "secret" },
{ key: "conf", type: "textarea", sensitive: true },
{ key: "port", type: "number" },
],
{ token: "abc", conf: "key", port: 1 },
),
).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 });
});
it("builds JSON Schema", () => {
const schema = toJsonSchema([
{ key: "port", type: "number", required: true, min: 1 },
{ key: "token", type: "secret" },
]) as { properties: Record<string, unknown>; required: string[] };
expect(schema.required).toEqual(["port"]);
expect(schema.properties.port).toEqual({ type: "number", minimum: 1 });
expect(schema.properties.token).toEqual({
type: "string",
writeOnly: true,
});
});
it("keeps AI and MCP opt in, the rest opt out", () => {
expect(onSurface({}, "assistant")).toBe(false);
expect(onSurface({}, "mcp")).toBe(false);
expect(onSurface({}, "cli")).toBe(true);
expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe(
true,
);
expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false);
});
it("matches invokes patterns", () => {
expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true);
expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false);
expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false);
expect(invokeAllowed(["*"], "anything.at-all")).toBe(true);
expect(invokeAllowed(undefined, "docker.stop")).toBe(false);
});
it("tests when against a host", () => {
const when = { settings: { enableDocker: true }, os: ["linux"] };
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }),
).toBe(true);
expect(matchesWhen(when, { settings: {} })).toBe(false);
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }),
).toBe(false);
expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true);
});
});
+12
View File
@@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
@@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request {
userId?: string;
sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
pendingTOTP?: boolean;
dataKey?: Buffer;
actingAdminUserId?: string;
@@ -879,8 +881,18 @@ class AuthManager {
});
});
const scopes = parseKeyScopes(matchedKey.scopes);
if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) {
res.status(403).json({
error: "This API key may not use this route",
code: "API_KEY_SCOPE",
});
return;
}
req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id;
req.apiKeyScopes = scopes;
next();
} catch (error) {
databaseLogger.error("API key authentication failed", error, {
+1
View File
@@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"admin.roles.manage",
"admin.settings.manage",
"admin.sessions.manage",
"admin.operations.view",
"admin.plugins.manage",
],
},
+12
View File
@@ -1214,6 +1214,18 @@
"title": "Send alerts",
"consequence": "To you or other users, and through the channels they set up."
},
"operations:provide": {
"title": "Offer actions",
"consequence": "Its actions can be run from Automations, the CLI, the phone app and other places, by people allowed to use them."
},
"operations:invoke": {
"title": "Run other plugins' actions",
"consequence": "Acts for the signed-in user on the actions its manifest lists. Approval rules still apply."
},
"operations:approve": {
"title": "Decide what needs approval",
"consequence": "Can let actions run with no approval, or block them, for every user."
},
"notify:hub": {
"title": "Receive every alert",
"consequence": "Stores and delivers the alerts all plugins send, including their contents."