mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
1220 lines
39 KiB
TypeScript
1220 lines
39 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* 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 { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
|
|
import { pluginEvents } from "./events.js";
|
|
import { createPluginDesktopLocal } from "./ctx-desktop-local.js";
|
|
import { createPluginAuditReader } from "./ctx-audit.js";
|
|
import { createPluginNetwork } from "./ctx-probe.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,
|
|
capabilityRefused,
|
|
hasCapability,
|
|
} from "./permissions.js";
|
|
import { getActor, runAsActor } from "./actor.js";
|
|
import { DisposableBag } from "./disposables.js";
|
|
import {
|
|
createPluginRouter,
|
|
createRbacMiddleware,
|
|
unregisterPluginHttp,
|
|
} from "./http.js";
|
|
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
|
import { resolvePermission } from "./rbac.js";
|
|
import * as pluginSettings from "./settings.js";
|
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
|
import {
|
|
type PluginContext,
|
|
type PluginModule,
|
|
type PluginOpenIsolatedWindowRequest,
|
|
type PluginExternalClientRequest,
|
|
} from "@termix-ssh/plugin-sdk/backend";
|
|
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
|
import * as syncRegistry from "./sync-registry.js";
|
|
import { provideSync } from "../sync/provider.js";
|
|
import { createSyncHost } from "../sync/host.js";
|
|
import type {
|
|
PluginSync,
|
|
PluginSyncHost,
|
|
} from "@termix-ssh/plugin-sdk/backend";
|
|
import {
|
|
needsExplicitPersist,
|
|
resolveDatabaseDialect,
|
|
} from "../database/db/dialect.js";
|
|
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 { createPluginProcess } from "./ctx-process.js";
|
|
import { createPluginSystem } from "./ctx-system.js";
|
|
import { createPluginPlugins } from "./ctx-plugins.js";
|
|
import { createPluginUsers } from "./ctx-users.js";
|
|
import { createPluginCredentials } from "./ctx-credentials.js";
|
|
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
|
|
|
export type { PluginModule };
|
|
|
|
/** Caps mirroring what the old worker boundary enforced. */
|
|
const MAX_KV_KEY_LENGTH = 128;
|
|
const MAX_KV_VALUE_BYTES = 256 * 1024;
|
|
|
|
/**
|
|
* How many keys one plugin may hold.
|
|
*
|
|
* The value cap alone bounds a single row, not the table: a plugin writing
|
|
* unique keys in a loop could still fill the database. Configurable because
|
|
* the right ceiling depends on the install, not on the plugin.
|
|
*/
|
|
const DEFAULT_MAX_KV_KEYS = 10_000;
|
|
|
|
function maxKvKeys(env: NodeJS.ProcessEnv = process.env): number {
|
|
const raw = Number(env.PLUGIN_MAX_KV_KEYS);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_KV_KEYS;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* Audit only the first success. For handles plugins fetch before every
|
|
* query: an audit row per call rewrote the whole database each time.
|
|
*/
|
|
once?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Wraps a privileged function in the capability check and the audit line.
|
|
*
|
|
* The check runs before the call and the audit after. A denied call is still
|
|
* recorded, by assertCapability: "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> {
|
|
let audited = false;
|
|
return async (...args: Args): Promise<Result> => {
|
|
// A refusal is audited inside assertCapability, under this action.
|
|
await assertCapability(
|
|
manifest.id,
|
|
capability,
|
|
manifest.capabilities,
|
|
options.action,
|
|
);
|
|
|
|
try {
|
|
const result = await fn(...args);
|
|
if (!options.once || !audited) {
|
|
audited = true;
|
|
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() ?? null,
|
|
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;
|
|
let syncHost: PluginSyncHost | undefined;
|
|
const syncApi: PluginSync = {
|
|
registerEntity: (entity) => {
|
|
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
|
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
|
},
|
|
provide: (provider) => {
|
|
if (!manifest.capabilities.includes("sync:provide")) {
|
|
throw capabilityRefused(pluginId, "sync:provide");
|
|
}
|
|
const remove = provideSync(pluginId, provider);
|
|
const drop = handle.bag.add(remove, "sync provider");
|
|
return () => {
|
|
drop();
|
|
remove();
|
|
};
|
|
},
|
|
} as PluginSync;
|
|
// Not enumerable, so tools that walk the context do not trip the check.
|
|
Object.defineProperty(syncApi, "host", {
|
|
enumerable: false,
|
|
get() {
|
|
if (!manifest.capabilities.includes("sync:provide")) {
|
|
throw capabilityRefused(pluginId, "sync:provide");
|
|
}
|
|
return (syncHost ??= createSyncHost());
|
|
},
|
|
});
|
|
const auditCall = (
|
|
action: string,
|
|
details: string,
|
|
outcome: { success: boolean; errorMessage?: string },
|
|
) => writeAudit(manifest, { action, details: () => details }, outcome);
|
|
// Forced: a plugin cannot log as another plugin.
|
|
const logContext = { operation: `plugin:${pluginId}` };
|
|
const declared = manifest.capabilities;
|
|
|
|
// ctx.secrets rows live in plugin_settings under the "secret" scope, one
|
|
// per user, so they go with the user and with the plugin.
|
|
const secretsRepository = async () => {
|
|
const { createCurrentPluginSettingsRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
return createCurrentPluginSettingsRepository();
|
|
};
|
|
// A user named by the plugin instead of the ambient actor. The same thing
|
|
// as ctx.asUser, so it needs the same capability and gets the same audit.
|
|
const namedUser = (
|
|
userId: string | undefined,
|
|
via: string,
|
|
): string | undefined => {
|
|
if (!userId) return getActor();
|
|
if (userId !== getActor()) {
|
|
if (!declared.includes("users:impersonate")) {
|
|
throw capabilityRefused(pluginId, "users:impersonate");
|
|
}
|
|
void writeAudit(
|
|
manifest,
|
|
{
|
|
action: "as_user",
|
|
details: () => `${pluginId} acted as ${userId} through ${via}`,
|
|
},
|
|
{ success: true },
|
|
);
|
|
}
|
|
return userId;
|
|
};
|
|
|
|
const secretOwner = (): string => {
|
|
const actor = getActor();
|
|
if (!actor) {
|
|
throw new Error(
|
|
"ctx.secrets needs an acting user: call it inside a request or ctx.asUser",
|
|
);
|
|
}
|
|
return actor;
|
|
};
|
|
|
|
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", once: true },
|
|
);
|
|
|
|
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");
|
|
const repository = createCurrentPluginStorageRepository();
|
|
|
|
// Only a new key can grow the table, so an overwrite is never blocked
|
|
// by a full one. Checked before the write, not after.
|
|
const existing = await repository.get(pluginId, key);
|
|
if (existing === null) {
|
|
const limit = maxKvKeys();
|
|
if ((await repository.countKeys(pluginId)) >= limit) {
|
|
throw new Error(
|
|
`Plugin "${pluginId}" has reached the ${limit} key limit for ctx.kv`,
|
|
);
|
|
}
|
|
}
|
|
|
|
await repository.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", once: true },
|
|
);
|
|
|
|
const filesDataDir = guarded(
|
|
manifest,
|
|
"files:own",
|
|
async () => {
|
|
const fs = await import("node:fs/promises");
|
|
const { getPluginDataDir } = await import("./paths.js");
|
|
const dir = getPluginDataDir(pluginId);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
return dir;
|
|
},
|
|
{ action: "files_data_dir", once: true },
|
|
);
|
|
|
|
const dbDefine = guarded(
|
|
manifest,
|
|
"db:own",
|
|
async (definition: PluginTableDefinition) => {
|
|
const { registerTable } = await import("./data.js");
|
|
return registerTable(pluginId, definition);
|
|
},
|
|
{ action: "db_define", details: () => "table definition registered" },
|
|
);
|
|
|
|
const dbClient = guarded(
|
|
manifest,
|
|
"db:own",
|
|
async () => {
|
|
const { getDb } = await import("../database/db/index.js");
|
|
return getDb();
|
|
},
|
|
{ action: "db_client", once: true },
|
|
);
|
|
|
|
// The one settings call that leaves the plugin's own namespace, so the one
|
|
// that needs a capability and an audit line.
|
|
const settingsReadCore = guarded(
|
|
manifest,
|
|
"settings:read-core",
|
|
async (key: string) => pluginSettings.readCoreSetting(key),
|
|
{
|
|
action: "settings_read_core",
|
|
details: () => "read a core server setting",
|
|
once: true,
|
|
},
|
|
);
|
|
|
|
const dbRefs = guarded(
|
|
manifest,
|
|
"db:core-refs",
|
|
async () => {
|
|
// Read-only by convention, not by engine: a plugin holding these can
|
|
// write through them, which is why the capability is rated high. The
|
|
// audit line is the record that it asked.
|
|
const schema = await import("../database/db/schema.js");
|
|
return {
|
|
users: schema.users,
|
|
hosts: schema.hosts,
|
|
roles: schema.roles,
|
|
userRoles: schema.userRoles,
|
|
};
|
|
},
|
|
{ action: "db_refs", once: true },
|
|
);
|
|
|
|
const desktopOpenIsolatedWindow = guarded(
|
|
manifest,
|
|
"desktop:window",
|
|
async (request: PluginOpenIsolatedWindowRequest) => {
|
|
const { isElectronIpcAvailable, requestFromElectronMain } =
|
|
await import("../utils/electron-ipc-bridge.js");
|
|
if (!isElectronIpcAvailable()) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} tried to open an isolated window outside the desktop app`,
|
|
);
|
|
}
|
|
return requestFromElectronMain<{ success: true }>(
|
|
"open-isolated-window",
|
|
request,
|
|
);
|
|
},
|
|
{
|
|
action: "desktop_open_isolated_window",
|
|
details: () => "opened an isolated Electron window",
|
|
},
|
|
);
|
|
|
|
const desktopLaunchExternalClient = guarded(
|
|
manifest,
|
|
"desktop:window",
|
|
async (request: PluginExternalClientRequest) => {
|
|
const { isElectronIpcAvailable, requestFromElectronMain } =
|
|
await import("../utils/electron-ipc-bridge.js");
|
|
if (!isElectronIpcAvailable()) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} tried to open an external client outside the desktop app`,
|
|
);
|
|
}
|
|
return requestFromElectronMain<{ success: boolean; error?: string }>(
|
|
"launch-external-client",
|
|
request,
|
|
);
|
|
},
|
|
{
|
|
action: "desktop_launch_external_client",
|
|
details: () => "opened an external client",
|
|
},
|
|
);
|
|
|
|
const capabilitiesRequire = async (capability: string) => {
|
|
await assertCapability(
|
|
pluginId,
|
|
capability,
|
|
declared,
|
|
"capability_require",
|
|
);
|
|
await writeAudit(
|
|
manifest,
|
|
{ action: "capability_require", details: () => capability },
|
|
{ success: true },
|
|
);
|
|
};
|
|
|
|
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);
|
|
},
|
|
|
|
// Core topics (host.*, user.*) carry other users' data, so listening
|
|
// needs events:core too. Any plugin may listen to plugin.* topics.
|
|
on: (topic, listener) => {
|
|
if (!topic.startsWith("plugin.") && !declared.includes("events:core")) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
|
|
`Declare the events:core capability to listen to core topics.`,
|
|
);
|
|
}
|
|
// A throwing listener counts against the plugin's error budget.
|
|
const counted: typeof listener = (payload) => {
|
|
try {
|
|
const result = listener(payload) as unknown;
|
|
if (
|
|
result &&
|
|
typeof (result as Promise<void>).then === "function"
|
|
) {
|
|
return (result as Promise<void>).catch((error) => {
|
|
void reportRuntimeError(pluginId, error);
|
|
throw error;
|
|
});
|
|
}
|
|
return result as never;
|
|
} catch (error) {
|
|
void reportRuntimeError(pluginId, error);
|
|
throw error;
|
|
}
|
|
};
|
|
const unsubscribe = pluginEvents.on(topic, counted);
|
|
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
|
|
return unsubscribe;
|
|
},
|
|
},
|
|
|
|
db: {
|
|
define: (definition) => dbDefine(definition) as never,
|
|
client: () => dbClient() as never,
|
|
refs: () => dbRefs() as never,
|
|
// Gated but not audited: it follows every write, and client() was
|
|
// already audited when the plugin first asked for it.
|
|
persist: async (options) => {
|
|
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
|
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
|
const { DatabaseSaveTrigger } =
|
|
await import("../utils/database-save-trigger.js");
|
|
if (options?.lazy) {
|
|
DatabaseSaveTrigger.triggerSave(`plugin_${pluginId}_write`);
|
|
return;
|
|
}
|
|
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
|
|
},
|
|
get dialect() {
|
|
return resolveDatabaseDialect();
|
|
},
|
|
},
|
|
|
|
sync: syncApi,
|
|
|
|
kv: {
|
|
get: (key) => kvGet(key),
|
|
set: (key, value) => kvSet(key, value),
|
|
delete: (key) => kvDelete(key),
|
|
list: () => kvList(),
|
|
},
|
|
|
|
files: {
|
|
dataDir: () => filesDataDir(),
|
|
},
|
|
|
|
registry: {
|
|
// Keys are namespaced by plugin id, so one plugin cannot replace or
|
|
// remove what another provides. Anyone may consume.
|
|
provide: (key, value) => {
|
|
assertOwnRegistryKey(pluginId, key);
|
|
registry.provide(key, value);
|
|
handle.bag.add(
|
|
() => void registry.revoke(key, value),
|
|
`registry key "${key}"`,
|
|
);
|
|
},
|
|
consume: (key) => registry.consume(key),
|
|
revoke: (key, value) => {
|
|
assertOwnRegistryKey(pluginId, key);
|
|
return registry.revoke(key, value);
|
|
},
|
|
},
|
|
|
|
services: {
|
|
provide: (service, implementation, options) => {
|
|
// 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 name = options?.name ?? "";
|
|
if (entry.names ? !entry.names.includes(name) : name !== "") {
|
|
throw new Error(
|
|
`Plugin ${pluginId} cannot provide service "${service}" as "${name}": the manifest's provides[].names does not list it`,
|
|
);
|
|
}
|
|
|
|
const registration = serviceRegistry.provideService({
|
|
service,
|
|
name,
|
|
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) => {
|
|
// Declared, like getShared: a plugin reaches only the services its
|
|
// manifest names in requires, or its own.
|
|
const known =
|
|
manifest.requires?.some((entry) => entry.service === service) ||
|
|
manifest.provides?.some((entry) => entry.service === service);
|
|
if (!known) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} cannot use service "${service}": it is not declared in the manifest's requires array`,
|
|
);
|
|
}
|
|
return serviceRegistry.createServiceHandle(
|
|
service,
|
|
pluginId,
|
|
{
|
|
// Naming a user other than the actor is the same thing as
|
|
// ctx.asUser, so it gets the same audit line.
|
|
resolveUserId: () =>
|
|
namedUser(options?.userId, `service ${service}`),
|
|
nameUser: (userId) => namedUser(userId, `service ${service}`),
|
|
hasPermission: checkPermission,
|
|
audit: (entry) => writeServiceAudit(entry),
|
|
},
|
|
options?.provider ?? "",
|
|
);
|
|
},
|
|
|
|
providers: (service) => serviceRegistry.listProviderNames(service),
|
|
},
|
|
|
|
secrets: {
|
|
get: async (key) => {
|
|
await assertCapability(pluginId, "secrets:own", declared);
|
|
const row = await (
|
|
await secretsRepository()
|
|
).get(pluginId, "secret", secretOwner(), key);
|
|
if (!row?.value) return null;
|
|
const { decryptSystemSecret } =
|
|
await import("../utils/system-secret-crypto.js");
|
|
return decryptSystemSecret(JSON.parse(row.value) as string);
|
|
},
|
|
|
|
set: guarded(
|
|
manifest,
|
|
"secrets:own",
|
|
async (key: string, value: string | null) => {
|
|
const repository = await secretsRepository();
|
|
const owner = secretOwner();
|
|
if (value === null || value === "") {
|
|
await repository.delete(pluginId, "secret", owner, key);
|
|
return;
|
|
}
|
|
const { encryptSystemSecret } =
|
|
await import("../utils/system-secret-crypto.js");
|
|
await repository.set(
|
|
pluginId,
|
|
"secret",
|
|
owner,
|
|
key,
|
|
JSON.stringify(await encryptSystemSecret(value)),
|
|
true,
|
|
);
|
|
},
|
|
{ action: "secret_set", details: () => "stored a secret" },
|
|
),
|
|
|
|
delete: guarded(
|
|
manifest,
|
|
"secrets:own",
|
|
async (key: string) => {
|
|
await (
|
|
await secretsRepository()
|
|
).delete(pluginId, "secret", secretOwner(), key);
|
|
},
|
|
{ action: "secret_delete", details: () => "deleted a secret" },
|
|
),
|
|
|
|
seal: async (value) => {
|
|
await assertCapability(pluginId, "secrets:own", declared);
|
|
const { encryptSystemSecret } =
|
|
await import("../utils/system-secret-crypto.js");
|
|
return encryptSystemSecret(value);
|
|
},
|
|
|
|
unseal: async (sealed) => {
|
|
await assertCapability(pluginId, "secrets:own", declared);
|
|
const { decryptSystemSecret, isSystemEncrypted } =
|
|
await import("../utils/system-secret-crypto.js");
|
|
// Only values seal() wrote: a plain string must not pass as sealed.
|
|
if (!sealed || !isSystemEncrypted(sealed)) return null;
|
|
try {
|
|
return await decryptSystemSecret(sealed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
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: () =>
|
|
namedUser(options?.userId, `shared secret ${providerPluginId}`),
|
|
hasPermission: checkPermission,
|
|
audit: (entry) => writeSecretAudit(entry),
|
|
}),
|
|
},
|
|
|
|
http: {
|
|
router: (options) => {
|
|
// Declared here, granted per request: the grant lives in the database
|
|
// and router() has to be synchronous so a plugin can register routes
|
|
// inline in activate. http.ts re-checks on every request.
|
|
if (!declared.includes("network:serve")) {
|
|
throw capabilityRefused(pluginId, "network:serve");
|
|
}
|
|
|
|
const router = createPluginRouter({
|
|
manifest,
|
|
options,
|
|
reportError: (error) => void reportRuntimeError(pluginId, error),
|
|
});
|
|
|
|
handle.bag.add(
|
|
() => unregisterPluginHttp(pluginId),
|
|
`HTTP router for ${pluginId}`,
|
|
);
|
|
return router as never;
|
|
},
|
|
baseUrl: (req) =>
|
|
getRequestBaseUrlWithForceHTTPS(
|
|
req as Parameters<typeof getRequestBaseUrlWithForceHTTPS>[0],
|
|
),
|
|
},
|
|
|
|
ws: {
|
|
route: (path, wsHandler, options) => {
|
|
if (!declared.includes("network:serve")) {
|
|
throw capabilityRefused(pluginId, "network:serve");
|
|
}
|
|
// Only a signed-in caller's socket counts against the error budget,
|
|
// so an anonymous peer cannot switch the plugin off.
|
|
const counted: typeof wsHandler = async (connection) => {
|
|
try {
|
|
return await wsHandler(connection);
|
|
} catch (error) {
|
|
if (connection.userId) void reportRuntimeError(pluginId, error);
|
|
throw error;
|
|
}
|
|
};
|
|
const dispose = registerPluginWsRoute(
|
|
pluginId,
|
|
path,
|
|
counted,
|
|
declared,
|
|
options,
|
|
);
|
|
handle.bag.add(dispose, `WebSocket route "${path}"`);
|
|
},
|
|
|
|
upgrade: (path, upgradeHandler, options) => {
|
|
if (!declared.includes("network:serve")) {
|
|
throw capabilityRefused(pluginId, "network:serve");
|
|
}
|
|
const dispose = registerPluginWsUpgrade(
|
|
pluginId,
|
|
path,
|
|
upgradeHandler as never,
|
|
declared,
|
|
options,
|
|
);
|
|
handle.bag.add(dispose, `WebSocket upgrade "${path}"`);
|
|
},
|
|
},
|
|
|
|
settings: {
|
|
// Reading and writing a plugin's OWN settings is ungated. The manifest
|
|
// already declares every field, and a plugin that had to ask permission
|
|
// to read its own configuration would be useless. Only readCore, which
|
|
// reaches outside the plugin's namespace, is a capability.
|
|
get: (key) =>
|
|
pluginSettings.getSetting(manifest, "admin", null, key) as never,
|
|
set: async (key, value) => {
|
|
const error = await pluginSettings.setSetting(
|
|
manifest,
|
|
"admin",
|
|
null,
|
|
key,
|
|
value,
|
|
);
|
|
if (error) throw new Error(error);
|
|
},
|
|
|
|
getUser: (userId, key) =>
|
|
pluginSettings.getSetting(manifest, "user", userId, key) as never,
|
|
setUser: async (userId, key, value) => {
|
|
const error = await pluginSettings.setSetting(
|
|
manifest,
|
|
"user",
|
|
userId,
|
|
key,
|
|
value,
|
|
);
|
|
if (error) throw new Error(error);
|
|
},
|
|
|
|
getHost: (hostId, key) =>
|
|
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
|
getHostFor: async (hostId, userId, key) => {
|
|
const { personalHostValue } =
|
|
await import("../hosts/defaults/personal.js");
|
|
const personal = await personalHostValue(
|
|
manifest,
|
|
Number(hostId),
|
|
userId,
|
|
key,
|
|
);
|
|
return (
|
|
personal.applies
|
|
? personal.value
|
|
: await pluginSettings.getSetting(manifest, "host", hostId, key)
|
|
) as never;
|
|
},
|
|
getHostDefault: async (userId, key) => {
|
|
const { userHostDefault } =
|
|
await import("../hosts/defaults/personal.js");
|
|
return (await userHostDefault(manifest, userId, key)) as never;
|
|
},
|
|
setHost: async (hostId, key, value, options) => {
|
|
const { changeHostOverrides } =
|
|
await import("../hosts/defaults/overrides.js");
|
|
if (options?.inherit) {
|
|
if (!pluginSettings.findField(manifest, "host", key)) {
|
|
throw new Error(
|
|
`"${key}" is not a host setting this plugin declares`,
|
|
);
|
|
}
|
|
await changeHostOverrides([Number(hostId)], {
|
|
inherit: [[pluginId, key]],
|
|
});
|
|
return;
|
|
}
|
|
const error = await pluginSettings.setSetting(
|
|
manifest,
|
|
"host",
|
|
hostId,
|
|
key,
|
|
value,
|
|
);
|
|
if (error) throw new Error(error);
|
|
await changeHostOverrides([Number(hostId)], { own: [[pluginId, key]] });
|
|
},
|
|
|
|
listHostValues: async (key) => {
|
|
await assertCapability(pluginId, "hosts:read", manifest.capabilities);
|
|
return pluginSettings.listHostValues(manifest, key) as never;
|
|
},
|
|
|
|
getAll: (scope, scopeId) =>
|
|
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
|
|
|
|
onChange: (key, listener) => {
|
|
const unsubscribe = pluginSettings.onSettingsChange(
|
|
pluginId,
|
|
key,
|
|
listener,
|
|
);
|
|
handle.bag.add(unsubscribe, `settings listener for "${key}"`);
|
|
return unsubscribe;
|
|
},
|
|
|
|
onValidate: (scope, validator) => {
|
|
const unsubscribe = pluginSettings.onSettingsValidate(
|
|
pluginId,
|
|
scope,
|
|
validator,
|
|
);
|
|
handle.bag.add(unsubscribe, `settings validator for ${scope}`);
|
|
return unsubscribe;
|
|
},
|
|
|
|
readCore: (key) => settingsReadCore(key),
|
|
},
|
|
|
|
rbac: {
|
|
// A short name takes this plugin's prefix; another plugin's id or a core
|
|
// group is used as given, which is what makes a cross-plugin check
|
|
// expressible without letting a plugin gate its own routes on it.
|
|
has: async (permission) => {
|
|
const actor = getActor();
|
|
if (!actor) return false;
|
|
return checkPermission(actor, resolvePermission(manifest, permission));
|
|
},
|
|
|
|
hasFor: async (userId, permission) => {
|
|
if (typeof userId !== "string" || userId.length === 0) return false;
|
|
return checkPermission(userId, resolvePermission(manifest, permission));
|
|
},
|
|
|
|
require: (permission) => createRbacMiddleware(manifest, permission),
|
|
},
|
|
|
|
capabilities: {
|
|
has: (capability) => hasCapability(pluginId, capability, declared),
|
|
require: capabilitiesRequire,
|
|
},
|
|
|
|
disposables: {
|
|
add: (dispose) => {
|
|
handle.bag.add(dispose, "plugin resource");
|
|
},
|
|
},
|
|
|
|
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
|
|
|
|
schedule: createPluginSchedule(handle.bag, (message, error) => {
|
|
pluginLogger.error(
|
|
message,
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
logContext,
|
|
);
|
|
void reportRuntimeError(pluginId, error);
|
|
}),
|
|
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
|
notify: createPluginNotify({
|
|
manifest,
|
|
audit: auditCall,
|
|
bag: handle.bag,
|
|
}),
|
|
fetch: createPluginFetch({ manifest, audit: auditCall }),
|
|
network: createPluginNetwork({ manifest, audit: auditCall }),
|
|
process: createPluginProcess({
|
|
manifest,
|
|
bag: handle.bag,
|
|
audit: auditCall,
|
|
}),
|
|
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
|
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
|
plugins: createPluginPlugins(manifest),
|
|
users: createPluginUsers(manifest),
|
|
|
|
desktop: {
|
|
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
|
launchExternalClient: (request) => desktopLaunchExternalClient(request),
|
|
available: () => isElectronIpcAvailable(),
|
|
local: createPluginDesktopLocal({
|
|
manifest,
|
|
bag: handle.bag,
|
|
audit: auditCall,
|
|
}),
|
|
},
|
|
|
|
credentials: createPluginCredentials({
|
|
manifest,
|
|
bag: handle.bag,
|
|
audit: auditCall,
|
|
}),
|
|
|
|
audit: {
|
|
// Attribution comes from the runtime: the actor, never a plugin value.
|
|
record: async (entry) => {
|
|
try {
|
|
const { logAudit } = await import("../utils/audit-logger.js");
|
|
const actor = getActor();
|
|
const meta = requestMeta(entry.request);
|
|
await logAudit({
|
|
...meta,
|
|
userId: actor ?? null,
|
|
username: actor ?? "system",
|
|
action: entry.action,
|
|
resourceType: entry.resourceType ?? "plugin",
|
|
resourceId: entry.resourceId ?? pluginId,
|
|
resourceName: entry.resourceName ?? manifest.name,
|
|
details: entry.details ?? `via plugin ${pluginId}`,
|
|
success: entry.success,
|
|
errorMessage: entry.errorMessage,
|
|
});
|
|
} catch {
|
|
// Auditing must never break the caller.
|
|
}
|
|
},
|
|
...createPluginAuditReader({
|
|
manifest,
|
|
bag: handle.bag,
|
|
audit: auditCall,
|
|
}),
|
|
},
|
|
|
|
/**
|
|
* 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`,
|
|
);
|
|
}
|
|
if (!declared.includes("users:impersonate")) {
|
|
throw capabilityRefused(pluginId, "users:impersonate");
|
|
}
|
|
await writeAudit(
|
|
manifest,
|
|
{
|
|
action: "as_user",
|
|
details: () => `${pluginId} ran background work as ${userId}`,
|
|
},
|
|
{ success: true },
|
|
);
|
|
return runAsActor(userId, "asUser", () => Promise.resolve(fn()));
|
|
},
|
|
|
|
currentActor: () => getActor(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Feeds a route or socket error into the loader's error budget.
|
|
*
|
|
* Lazily imported: index.ts owns the loader and imports this module, so a
|
|
* static import here would close the cycle.
|
|
*/
|
|
async function reportRuntimeError(
|
|
pluginId: string,
|
|
error: unknown,
|
|
): Promise<void> {
|
|
try {
|
|
const { getPluginRuntime } = await import("./index.js");
|
|
await getPluginRuntime().loader.reportError(pluginId, error);
|
|
} catch {
|
|
// The budget is a safety net, not a dependency of serving a request.
|
|
}
|
|
}
|
|
|
|
function assertOwnRegistryKey(pluginId: string, key: string): void {
|
|
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
pluginSettings.clearSettingsListeners(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" },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** IP address and user agent off an express request or upgrade request. */
|
|
function requestMeta(request: unknown): {
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
} {
|
|
const req = request as
|
|
| {
|
|
ip?: string;
|
|
headers?: Record<string, unknown>;
|
|
socket?: { remoteAddress?: string };
|
|
}
|
|
| undefined;
|
|
if (!req || typeof req !== "object" || !req.headers) return {};
|
|
const userAgent = req.headers["user-agent"];
|
|
return {
|
|
ipAddress: req.ip || req.socket?.remoteAddress || undefined,
|
|
userAgent: typeof userAgent === "string" ? userAgent : undefined,
|
|
};
|
|
}
|