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

@@ -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);
});
});