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

+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;
},
},
};
}