Files
Termix/src/backend/plugins/ctx.ts
T
a89ccc6b07 v26.10.3 (#1692)
* 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>
2026-10-20 16:03:29 -05:00

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