mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Builds the ctx a plugin's activate() receives.
|
||||
*
|
||||
* Every privileged member goes through guarded(), which checks the capability
|
||||
* against the plugin's grants and writes an audit line naming the plugin and
|
||||
* the acting user. That is the whole mechanism, and it is worth being precise
|
||||
* about what it buys:
|
||||
*
|
||||
* - It stops accidental and casual overreach. A plugin that never declared
|
||||
* credentials:read cannot reach a secret through ctx, and a plugin that
|
||||
* declared it leaves a trail every time it does.
|
||||
* - It does NOT contain malicious code. A plugin runs in the server process
|
||||
* and can import any module core can. Guarding ctx does not change that,
|
||||
* and this file does not pretend otherwise. Trust comes from signing,
|
||||
* review and the kill list. See ARCHITECTURE.md.
|
||||
*
|
||||
* The actor never comes from plugin code. It comes from AsyncLocalStorage,
|
||||
* set by a request or by ctx.asUser, so a plugin cannot name a user and be
|
||||
* believed.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { pluginEvents } from "./events.js";
|
||||
import * as registry from "./registry.js";
|
||||
import * as serviceRegistry from "./service-registry.js";
|
||||
import type { ServiceRegistration } from "./service-registry.js";
|
||||
import * as secretRegistry from "./secret-registry.js";
|
||||
import type { SecretRegistration } from "./secret-registry.js";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor, runAsActor } from "./actor.js";
|
||||
import { DisposableBag } from "./disposables.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type { PluginModule };
|
||||
|
||||
/** Caps mirroring what the old worker boundary enforced. */
|
||||
const MAX_KV_KEY_LENGTH = 128;
|
||||
const MAX_KV_VALUE_BYTES = 256 * 1024;
|
||||
|
||||
export interface PluginHandle {
|
||||
module: PluginModule;
|
||||
bag: DisposableBag;
|
||||
providedServices: ServiceRegistration[];
|
||||
offeredSecrets: SecretRegistration[];
|
||||
}
|
||||
|
||||
export function createPluginHandle(
|
||||
pluginId: string,
|
||||
module: PluginModule,
|
||||
): PluginHandle {
|
||||
return {
|
||||
module,
|
||||
bag: new DisposableBag(pluginId),
|
||||
providedServices: [],
|
||||
offeredSecrets: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface AuditOptions {
|
||||
/** Audit action suffix, e.g. "kv_set". */
|
||||
action: string;
|
||||
/** Short description of the call shape. Never a value. */
|
||||
details?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a privileged function in the capability check and the audit line.
|
||||
*
|
||||
* The check runs before the call and the audit after, so a denied call is
|
||||
* still recorded: "tried and was refused" is exactly the thing worth seeing.
|
||||
*/
|
||||
function guarded<Args extends unknown[], Result>(
|
||||
manifest: PluginManifest,
|
||||
capability: string,
|
||||
fn: (...args: Args) => Promise<Result>,
|
||||
options: AuditOptions,
|
||||
): (...args: Args) => Promise<Result> {
|
||||
return async (...args: Args): Promise<Result> => {
|
||||
const pluginId = manifest.id;
|
||||
|
||||
try {
|
||||
await assertCapability(pluginId, capability, manifest.capabilities);
|
||||
} catch (error) {
|
||||
await writeAudit(manifest, options, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
await writeAudit(manifest, options, { success: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
await writeAudit(manifest, options, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function writeAudit(
|
||||
manifest: PluginManifest,
|
||||
options: AuditOptions,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: `plugin_${options.action}`,
|
||||
resourceType: "plugin",
|
||||
resourceId: manifest.id,
|
||||
resourceName: manifest.name,
|
||||
details: options.details?.(),
|
||||
success: outcome.success,
|
||||
errorMessage: outcome.errorMessage,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never break the caller.
|
||||
}
|
||||
}
|
||||
|
||||
export function createPluginContext(
|
||||
manifest: PluginManifest,
|
||||
handle: PluginHandle,
|
||||
): PluginContext {
|
||||
const pluginId = manifest.id;
|
||||
// Forced: a plugin cannot log as another plugin.
|
||||
const logContext = { operation: `plugin:${pluginId}` };
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
const kvGet = guarded(
|
||||
manifest,
|
||||
"kv:own",
|
||||
async (key: string) => {
|
||||
const { createCurrentPluginStorageRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const raw = await createCurrentPluginStorageRepository().get(
|
||||
pluginId,
|
||||
key,
|
||||
);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ action: "kv_get" },
|
||||
);
|
||||
|
||||
const kvSet = guarded(
|
||||
manifest,
|
||||
"kv:own",
|
||||
async (key: string, value: unknown) => {
|
||||
assertKvKey(key);
|
||||
const serialized = JSON.stringify(value ?? null);
|
||||
if (Buffer.byteLength(serialized, "utf8") > MAX_KV_VALUE_BYTES) {
|
||||
throw new Error(
|
||||
`Value for "${key}" exceeds the ${MAX_KV_VALUE_BYTES} byte limit for ctx.kv`,
|
||||
);
|
||||
}
|
||||
const { createCurrentPluginStorageRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentPluginStorageRepository().set(
|
||||
pluginId,
|
||||
key,
|
||||
serialized,
|
||||
);
|
||||
},
|
||||
{ action: "kv_set" },
|
||||
);
|
||||
|
||||
const kvDelete = guarded(
|
||||
manifest,
|
||||
"kv:own",
|
||||
async (key: string) => {
|
||||
const { createCurrentPluginStorageRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginStorageRepository().delete(pluginId, key);
|
||||
},
|
||||
{ action: "kv_delete" },
|
||||
);
|
||||
|
||||
const kvList = guarded(
|
||||
manifest,
|
||||
"kv:own",
|
||||
async () => {
|
||||
const { createCurrentPluginStorageRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginStorageRepository().listKeys(pluginId);
|
||||
},
|
||||
{ action: "kv_list" },
|
||||
);
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
manifest,
|
||||
|
||||
log: {
|
||||
debug: (message) => pluginLogger.debug(message, logContext),
|
||||
info: (message) => pluginLogger.info(message, logContext),
|
||||
warn: (message) => pluginLogger.warn(message, logContext),
|
||||
error: (message, error) => pluginLogger.error(message, error, logContext),
|
||||
},
|
||||
|
||||
events: {
|
||||
/**
|
||||
* A plugin may only emit under its own namespace unless it holds
|
||||
* events:core. Without this an in-process plugin can publish
|
||||
* "host.status" and drive the automations engine as if core had.
|
||||
*/
|
||||
emit: (topic, payload) => {
|
||||
if (
|
||||
!topic.startsWith(`plugin.${pluginId}.`) &&
|
||||
!declared.includes("events:core")
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
|
||||
`Declare the events:core capability to emit core topics.`,
|
||||
);
|
||||
}
|
||||
pluginEvents.emit(topic, payload);
|
||||
},
|
||||
|
||||
on: (topic, listener) => {
|
||||
const unsubscribe = pluginEvents.on(topic, listener);
|
||||
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
|
||||
return unsubscribe;
|
||||
},
|
||||
},
|
||||
|
||||
kv: {
|
||||
get: (key) => kvGet(key),
|
||||
set: (key, value) => kvSet(key, value),
|
||||
delete: (key) => kvDelete(key),
|
||||
list: () => kvList(),
|
||||
},
|
||||
|
||||
registry: {
|
||||
provide: (key, value) => {
|
||||
registry.provide(key, value);
|
||||
handle.bag.add(
|
||||
() => void registry.revoke(key, value),
|
||||
`registry key "${key}"`,
|
||||
);
|
||||
},
|
||||
consume: (key) => registry.consume(key),
|
||||
revoke: (key, value) => registry.revoke(key, value),
|
||||
},
|
||||
|
||||
services: {
|
||||
provide: (service, implementation) => {
|
||||
// Declared AND provided: a plugin cannot publish a service its
|
||||
// manifest never mentioned.
|
||||
const entry = manifest.provides?.find(
|
||||
(candidate) => candidate.service === service,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = serviceRegistry.provideService({
|
||||
service,
|
||||
version: entry.version,
|
||||
permission: entry.permission,
|
||||
pluginId,
|
||||
pluginName: manifest.name,
|
||||
implementation: implementation as Record<string, unknown>,
|
||||
});
|
||||
handle.providedServices.push(registration);
|
||||
handle.bag.add(
|
||||
() => void serviceRegistry.revokeService(service, registration),
|
||||
`service "${service}"`,
|
||||
);
|
||||
},
|
||||
|
||||
get: (service, options) =>
|
||||
serviceRegistry.createServiceHandle(service, pluginId, {
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
}),
|
||||
},
|
||||
|
||||
secrets: {
|
||||
offer: (key, resolve) => {
|
||||
const entry = manifest.providesSecret?.find(
|
||||
(candidate) => candidate.key === key,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = secretRegistry.offerSecret({
|
||||
pluginId,
|
||||
pluginName: manifest.name,
|
||||
key,
|
||||
permission: entry.permission,
|
||||
resolve,
|
||||
});
|
||||
handle.offeredSecrets.push(registration);
|
||||
handle.bag.add(
|
||||
() => void secretRegistry.withdrawSecret(pluginId, key, registration),
|
||||
`secret "${key}"`,
|
||||
);
|
||||
},
|
||||
|
||||
withdraw: (key) => {
|
||||
const registration = handle.offeredSecrets.find(
|
||||
(entry) => entry.key === key,
|
||||
);
|
||||
handle.offeredSecrets = handle.offeredSecrets.filter(
|
||||
(entry) => entry !== registration,
|
||||
);
|
||||
return secretRegistry.withdrawSecret(pluginId, key, registration);
|
||||
},
|
||||
|
||||
getShared: (providerPluginId, key, options) =>
|
||||
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeSecretAudit(entry),
|
||||
}),
|
||||
},
|
||||
|
||||
disposables: {
|
||||
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Background work acts as a named user. Always audited, because "this ran
|
||||
* as someone" is exactly the thing an operator needs to be able to see.
|
||||
*/
|
||||
asUser: async (userId, fn) => {
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} called ctx.asUser without a user id`,
|
||||
);
|
||||
}
|
||||
await writeAudit(
|
||||
manifest,
|
||||
{
|
||||
action: "as_user",
|
||||
details: () => `${pluginId} ran background work as ${userId}`,
|
||||
},
|
||||
{ success: true },
|
||||
);
|
||||
return runAsActor(userId, "asUser", () => Promise.resolve(fn()));
|
||||
},
|
||||
|
||||
currentActor: () => getActor(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertKvKey(key: string): void {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error("ctx.kv keys must be non-empty strings");
|
||||
}
|
||||
if (key.length > MAX_KV_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`ctx.kv keys must be at most ${MAX_KV_KEY_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPermission(
|
||||
userId: string,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||
return PermissionManager.getInstance().hasPermission(userId, permission);
|
||||
}
|
||||
|
||||
/** Attribution comes from the runtime; details only record the call shape. */
|
||||
async function writeServiceAudit(
|
||||
entry: serviceRegistry.ServiceAuditEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: entry.userId,
|
||||
username: `plugin:${entry.consumerPluginId}`,
|
||||
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
|
||||
resourceType: "plugin",
|
||||
resourceId: entry.registration.pluginId,
|
||||
resourceName: entry.registration.pluginName,
|
||||
details: `${entry.consumerPluginId} called ${entry.registration.service}.${entry.method}`,
|
||||
success: entry.success,
|
||||
errorMessage: entry.errorMessage,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never break the caller.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a secret was borrowed, never what it was. `resolved` is the
|
||||
* only thing said about the value: whether one came back at all.
|
||||
*/
|
||||
async function writeSecretAudit(
|
||||
entry: secretRegistry.SecretAuditEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: entry.userId,
|
||||
username: `plugin:${entry.consumerPluginId}`,
|
||||
action: "plugin_secret_shared_read",
|
||||
resourceType: "plugin",
|
||||
resourceId: entry.providerPluginId,
|
||||
resourceName: entry.providerPluginName,
|
||||
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
|
||||
success: entry.success,
|
||||
errorMessage: entry.errorMessage,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never break the caller.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes everything the plugin registered, then runs its own deactivate().
|
||||
*
|
||||
* The plugin's cleanup runs last so it can still emit or read while shutting
|
||||
* down, and its throwing does not stop the bag from being emptied.
|
||||
*/
|
||||
export async function disposePluginHandle(
|
||||
handle: PluginHandle,
|
||||
pluginId: string,
|
||||
options: { runDeactivate?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await handle.bag.disposeAll();
|
||||
|
||||
handle.providedServices = [];
|
||||
handle.offeredSecrets = [];
|
||||
// Belt and braces: an offer made outside ctx, or one whose registration was
|
||||
// lost to a throw mid-activate, would otherwise outlive the plugin.
|
||||
secretRegistry.withdrawAllForPlugin(pluginId);
|
||||
|
||||
// Skipped when activate() never finished: a plugin's deactivate expects the
|
||||
// state activate builds, and calling it on a half-built plugin tends to
|
||||
// throw over the original error and hide it.
|
||||
const runDeactivate = options.runDeactivate ?? true;
|
||||
|
||||
if (runDeactivate && typeof handle.module.deactivate === "function") {
|
||||
try {
|
||||
await handle.module.deactivate();
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
`Plugin ${pluginId} deactivate() failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_deactivate" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user