From 225f6183376a9234c07e58ebcd6ad15e8317e42c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 21 Sep 2026 16:23:00 -0500 Subject: [PATCH] feat: add a permission-gated backend service registry for plugins --- package-lock.json | 8 + package.json | 2 + plugins/ai/manifest.json | 14 +- .../manifests/invalid-service-permission.json | 36 ++ .../manifests/valid-services.json | 43 ++ scripts/plugin-manifest.schema.json | 50 +++ scripts/validate-plugin-manifest.cjs | 108 +++++ scripts/validate-plugin-manifest.test.ts | 12 + src/backend/plugins/broker.ts | 2 +- src/backend/plugins/host-ctx.ts | 91 ++++ src/backend/plugins/index.ts | 116 +++++ src/backend/plugins/loader.ts | 16 + src/backend/plugins/manifest.ts | 122 ++++++ src/backend/plugins/service-registry.ts | 414 ++++++++++++++++++ src/backend/tests/plugins/manifest.test.ts | 97 ++++ .../tests/plugins/service-contract.test.ts | 342 +++++++++++++++ .../tests/plugins/service-registry.test.ts | 284 ++++++++++++ 17 files changed, 1755 insertions(+), 2 deletions(-) create mode 100644 scripts/__fixtures__/manifests/invalid-service-permission.json create mode 100644 scripts/__fixtures__/manifests/valid-services.json create mode 100644 src/backend/plugins/service-registry.ts create mode 100644 src/backend/tests/plugins/service-contract.test.ts create mode 100644 src/backend/tests/plugins/service-registry.test.ts diff --git a/package-lock.json b/package-lock.json index 5b292eb14..857cb3373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@tanstack/react-virtual": "^3.14.13", "@types/compression": "^1.8.1", "@types/ldapjs": "^3.0.6", + "@types/semver": "^7.8.0", "axios": "^1.19.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^13.0.3", @@ -40,6 +41,7 @@ "pg": "^8.23.0", "qrcode": "^1.5.4", "redis": "^6.2.1", + "semver": "^7.8.5", "serialport": "^13.0.0", "sharp": "^0.35.4", "socks": "^2.8.10", @@ -7413,6 +7415,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", diff --git a/package.json b/package.json index 415634b35..cd7194ab5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tanstack/react-virtual": "^3.14.13", "@types/compression": "^1.8.1", "@types/ldapjs": "^3.0.6", + "@types/semver": "^7.8.0", "axios": "^1.19.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^13.0.3", @@ -78,6 +79,7 @@ "pg": "^8.23.0", "qrcode": "^1.5.4", "redis": "^6.2.1", + "semver": "^7.8.5", "serialport": "^13.0.0", "sharp": "^0.35.4", "socks": "^2.8.10", diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index 7e7746a14..f314ca8e4 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -24,7 +24,19 @@ "icon": "Sparkles", "openFrom": ["rail", "palette"] } - ] + ], + "permissionGroup": { + "group": "ai", + "permissions": [ + "ai.use", + "ai.manage_providers", + "ai.apply_proposals", + "ai.services.use" + ], + "defaultForRole": { + "admin": ["ai.services.use"] + } + } }, "sidecars": [] } diff --git a/scripts/__fixtures__/manifests/invalid-service-permission.json b/scripts/__fixtures__/manifests/invalid-service-permission.json new file mode 100644 index 000000000..1a289489a --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-service-permission.json @@ -0,0 +1,36 @@ +{ + "id": "sample-provider", + "name": "Sample Provider", + "version": "1.0.0", + "description": "Gates a service behind a permission its own group never declares.", + "author": { + "name": "Jane Doe" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": { + "backend": true, + "frontend": false, + "electron": false, + "platforms": ["linux"] + }, + "permissions": ["hosts.read"], + "contributes": { + "permissionGroup": { + "group": "sampleprovider", + "permissions": ["sampleprovider.greet.use"] + } + }, + "provides": [ + { + "service": "sampleprovider.greet", + "version": "1.2.0", + "permission": "sampleprovider.undeclared" + } + ], + "sidecars": [] +} diff --git a/scripts/__fixtures__/manifests/valid-services.json b/scripts/__fixtures__/manifests/valid-services.json new file mode 100644 index 000000000..030652a2b --- /dev/null +++ b/scripts/__fixtures__/manifests/valid-services.json @@ -0,0 +1,43 @@ +{ + "id": "sample-provider", + "name": "Sample Provider", + "version": "1.0.0", + "description": "A plugin that both provides and requires a named service.", + "author": { + "name": "Jane Doe" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": { + "backend": true, + "frontend": false, + "electron": false, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["hosts.read"], + "contributes": { + "permissionGroup": { + "group": "sampleprovider", + "permissions": ["sampleprovider.greet.use"] + } + }, + "provides": [ + { + "service": "sampleprovider.greet", + "version": "1.2.0", + "permission": "sampleprovider.greet.use" + } + ], + "requires": [ + { + "service": "other.thing", + "versionRange": "^1.0.0", + "optional": true + } + ], + "sidecars": [] +} diff --git a/scripts/plugin-manifest.schema.json b/scripts/plugin-manifest.schema.json index 645509867..4c3379f50 100644 --- a/scripts/plugin-manifest.schema.json +++ b/scripts/plugin-manifest.schema.json @@ -224,6 +224,56 @@ } } }, + "provides": { + "type": "array", + "description": "Named services this plugin exposes to other plugins.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["service", "version", "permission"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$", + "description": "Dotted service name, e.g. \"ssh.transport\"." + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$", + "description": "Semver version of this service contract." + }, + "permission": { + "type": "string", + "minLength": 1, + "description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions." + } + } + } + }, + "requires": { + "type": "array", + "description": "Named services this plugin needs another active plugin to provide.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["service", "versionRange"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$" + }, + "versionRange": { + "type": "string", + "minLength": 1, + "description": "Semver range, e.g. \"^1.2.0\"." + }, + "optional": { + "type": "boolean", + "description": "When true, an unsatisfied requirement is skipped instead of failing activation." + } + } + } + }, "sidecars": { "type": "array", "items": { diff --git a/scripts/validate-plugin-manifest.cjs b/scripts/validate-plugin-manifest.cjs index 2b7d16f99..3d95e5524 100644 --- a/scripts/validate-plugin-manifest.cjs +++ b/scripts/validate-plugin-manifest.cjs @@ -10,12 +10,14 @@ const fs = require("fs"); const path = require("path"); +const semver = require("semver"); const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json"); const ID_PATTERN = /^[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; const API_VERSION_PATTERN = /^[0-9]+$/; +const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; function loadSchema() { @@ -123,10 +125,116 @@ function validateManifest(manifest, schema) { errors.push('Field "sidecars" must be an array'); } + errors.push(...validateProvides(manifest.provides)); + errors.push(...validateRequires(manifest.requires)); + if (manifest.contributes && typeof manifest.contributes === "object") { errors.push(...validateContributes(manifest.contributes)); } + // A service permission has to be one the plugin's own permissionGroup + // declares, or no admin could ever grant it. + const declared = manifest.contributes?.permissionGroup?.permissions; + if (Array.isArray(manifest.provides) && Array.isArray(declared)) { + for (const entry of manifest.provides) { + if (entry && entry.permission && !declared.includes(entry.permission)) { + errors.push( + `Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, + ); + } + } + } + + return errors; +} + +function validateProvides(provides) { + if (provides === undefined) return []; + if (!Array.isArray(provides)) return ['Field "provides" must be an array']; + + const errors = []; + const seen = new Set(); + + provides.forEach((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`provides[${index}] must be an object`); + return; + } + + if ( + typeof entry.service !== "string" || + !SERVICE_PATTERN.test(entry.service) + ) { + errors.push( + `provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`, + ); + } else if (seen.has(entry.service)) { + errors.push( + `provides[${index}].service is a duplicate: "${entry.service}"`, + ); + } else { + seen.add(entry.service); + } + + if ( + typeof entry.version !== "string" || + !SEMVER_PATTERN.test(entry.version) + ) { + errors.push( + `provides[${index}].version must be valid semver, got: "${entry.version}"`, + ); + } + + if (typeof entry.permission !== "string" || entry.permission.length === 0) { + errors.push(`provides[${index}].permission is required`); + } + }); + + return errors; +} + +function validateRequires(requires) { + if (requires === undefined) return []; + if (!Array.isArray(requires)) return ['Field "requires" must be an array']; + + const errors = []; + const seen = new Set(); + + requires.forEach((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`requires[${index}] must be an object`); + return; + } + + if ( + typeof entry.service !== "string" || + !SERVICE_PATTERN.test(entry.service) + ) { + errors.push( + `requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`, + ); + } else if (seen.has(entry.service)) { + errors.push( + `requires[${index}].service is a duplicate: "${entry.service}"`, + ); + } else { + seen.add(entry.service); + } + + if ( + typeof entry.versionRange !== "string" || + semver.validRange(entry.versionRange) === null + ) { + errors.push( + `requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`, + ); + } + + if ("optional" in entry && typeof entry.optional !== "boolean") { + errors.push(`requires[${index}].optional must be a boolean`); + } + }); + return errors; } diff --git a/scripts/validate-plugin-manifest.test.ts b/scripts/validate-plugin-manifest.test.ts index c24dcde85..a3f10717a 100644 --- a/scripts/validate-plugin-manifest.test.ts +++ b/scripts/validate-plugin-manifest.test.ts @@ -38,6 +38,18 @@ describe("validate-plugin-manifest.cjs", () => { expect(output).toMatch(/Field "id" must match/); }); + it("accepts a manifest declaring provides and requires", () => { + const { status, output } = runValidator("valid-services.json"); + expect(status).toBe(0); + expect(output).toContain("Valid plugin manifest"); + }); + + it("rejects a service gated by an undeclared permission", () => { + const { status, output } = runValidator("invalid-service-permission.json"); + expect(status).not.toBe(0); + expect(output).toContain("sampleprovider.undeclared"); + }); + it("rejects a manifest with an unknown permission", () => { const { status, output } = runValidator("invalid-unknown-permission.json"); expect(status).not.toBe(0); diff --git a/src/backend/plugins/broker.ts b/src/backend/plugins/broker.ts index 3d4f5f174..59669123d 100644 --- a/src/backend/plugins/broker.ts +++ b/src/backend/plugins/broker.ts @@ -771,7 +771,7 @@ function storageKey(raw: unknown): string { return key; } -function safeDetails(method: string, args: unknown[]): string { +export function safeDetails(method: string, args: unknown[]): string { const summary = args.map((arg) => { if (typeof arg === "number" || typeof arg === "boolean") return arg; if (typeof arg === "string") { diff --git a/src/backend/plugins/host-ctx.ts b/src/backend/plugins/host-ctx.ts index 665fb6697..0b23828f0 100644 --- a/src/backend/plugins/host-ctx.ts +++ b/src/backend/plugins/host-ctx.ts @@ -17,6 +17,9 @@ 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 { safeDetails } from "./broker.js"; import type { PluginManifest } from "./manifest.js"; export interface InProcessPluginContext { @@ -43,6 +46,18 @@ export interface InProcessPluginContext { consume: (key: string) => T | undefined; revoke: (key: string, value?: unknown) => boolean; }; + /** + * Declared service contracts between plugins. Unlike `registry` above, a + * service must be declared in the manifest and every call through a handle + * is checked against the acting user's role permissions. + */ + services: { + provide: (service: string, implementation: T) => void; + get: ( + service: string, + options?: { userId?: string }, + ) => T; + }; } /** @@ -56,6 +71,8 @@ export interface InProcessHandle { unsubscribers: Array<() => void>; /** Registry keys this plugin provided, revoked on deactivate. */ providedKeys: Array<{ key: string; value: unknown }>; + /** Declared services this plugin provided, revoked on deactivate. */ + providedServices: ServiceRegistration[]; } export interface PluginModule { @@ -135,9 +152,76 @@ export function createInProcessContext( consume: (key) => registry.consume(key), revoke: (key, value) => registry.revoke(key, value), }, + + services: { + provide: (service, implementation) => { + // Declared AND provided, the same shape the capability gate uses: a + // plugin cannot publish a service its manifest never mentioned. + const declared = manifest.provides?.find( + (entry) => entry.service === service, + ); + if (!declared) { + 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: declared.version, + permission: declared.permission, + pluginId, + pluginName: manifest.name, + implementation: implementation as Record, + }); + handle.providedServices.push(registration); + }, + + get: (service, options) => + serviceRegistry.createServiceHandle(service, pluginId, { + resolveUserId: () => options?.userId, + hasPermission: async (userId, permission) => { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + return PermissionManager.getInstance().hasPermission( + userId, + permission, + ); + }, + audit: (entry) => writeServiceAudit(entry), + }), + }, }; } +/** + * Mirrors PluginBroker.audit: attribution comes from the runtime, never from + * the plugin, and the details only ever record the shape of a call. + */ +async function writeServiceAudit( + entry: serviceRegistry.ServiceAuditEntry, +): Promise { + 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: safeDetails( + `${entry.registration.service}.${entry.method}`, + entry.args, + ), + success: entry.success, + errorMessage: entry.errorMessage, + }); + } catch { + // Auditing must never break the caller. + } +} + /** Undoes everything the ctx handed out. Safe to call more than once. */ export async function disposeInProcessHandle( handle: InProcessHandle, @@ -148,6 +232,13 @@ export async function disposeInProcessHandle( } handle.providedKeys = []; + // Taking the services with it is what makes a consumer's held handle start + // throwing PluginServiceUnavailableError rather than calling a dead provider. + for (const registration of handle.providedServices) { + serviceRegistry.revokeService(registration.service, registration); + } + handle.providedServices = []; + for (const unsubscribe of handle.unsubscribers) { try { unsubscribe(); diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 0196a1a40..b404d7fb3 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -254,11 +254,118 @@ export async function initializePlugins(): Promise { return loaded; } +/** + * Puts a plugin's declared permissions into the role catalog. + * + * Until this ran, contributes.permissionGroup was validated but never + * consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id + * rejects any string isValidPermission does not know. Registering here is what + * makes a plugin permission appear in the admin role editor exactly like + * hosts.view does. + */ +async function registerPluginPermissions(plugin: LoadedPlugin): Promise { + const group = plugin.manifest.contributes?.permissionGroup; + if (!group) return; + + const { registerPermissionGroup } = + await import("../utils/permission-catalog.js"); + registerPermissionGroup({ + group: group.group, + permissions: group.permissions, + }); + + if (group.defaultForRole) { + await applyRoleDefaults(plugin.id, group.defaultForRole); + } +} + +/** + * Applies the plugin's declared role suggestion. + * + * Additive only, and only for a permission the role does not already list. + * An admin's later revoke must never be re-granted on the next restart, which + * is the same rule ensureSystemRoles follows for the built-in roles. + */ +async function applyRoleDefaults( + pluginId: string, + defaults: Record, +): Promise { + const { createCurrentRoleRepository } = + await import("../database/repositories/factory.js"); + const { PermissionManager } = await import("../utils/permission-manager.js"); + const repository = createCurrentRoleRepository(); + + for (const [roleName, wanted] of Object.entries(defaults)) { + try { + const role = await repository.findRoleByName(roleName); + if (!role) continue; + + let current: unknown; + try { + current = role.permissions ? JSON.parse(role.permissions) : []; + } catch { + continue; + } + if (!Array.isArray(current)) continue; + + // A wildcard the role already holds covers these, so adding them would + // only be noise in the editor. + const missing = wanted.filter( + (permission) => !coveredBy(current as string[], permission), + ); + if (missing.length === 0) continue; + + await repository.updateRole(role.id, { + permissions: JSON.stringify([...(current as string[]), ...missing]), + }); + + const memberIds = await repository.listRoleUserIds(role.id); + for (const memberId of memberIds) { + PermissionManager.getInstance().invalidateUserPermissionCache(memberId); + } + } catch (error) { + pluginLogger.warn( + `Could not apply ${pluginId} role defaults for ${roleName}: ${ + error instanceof Error ? error.message : String(error) + }`, + { operation: "plugin_permissions" }, + ); + } + } +} + +function coveredBy(permissions: string[], permission: string): boolean { + if (permissions.includes("*") || permissions.includes(permission)) { + return true; + } + const parts = permission.split("."); + for (let i = parts.length; i > 0; i--) { + if (permissions.includes(`${parts.slice(0, i).join(".")}.*`)) return true; + } + return false; +} + export async function activatePlugin( pluginId: string, ownerUserId?: string, ): Promise { const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime(); + + // Before activate: a plugin's own activate() may already want to check one + // of its permissions. + const plugin = pluginLoader.get(pluginId); + if (plugin) { + try { + await registerPluginPermissions(plugin); + } catch (error) { + pluginLogger.error( + `Failed to register permissions for ${pluginId}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_permissions" }, + ); + } + } + await pluginLoader.activate(pluginId, ownerUserId); const runtime = pluginBroker.get(pluginId); @@ -267,8 +374,17 @@ export async function activatePlugin( export async function deactivatePlugin(pluginId: string): Promise { const { loader: pluginLoader } = getPluginRuntime(); + const plugin = pluginLoader.get(pluginId); + await pluginLoader.deactivate(pluginId); unregisterPluginRouter(pluginId); + + const group = plugin?.manifest.contributes?.permissionGroup; + if (group) { + const { unregisterPermissionGroup } = + await import("../utils/permission-catalog.js"); + unregisterPermissionGroup(group.group); + } } export async function shutdownPlugins(): Promise { diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index cfa3e4de8..d95ab016e 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -54,6 +54,7 @@ import { type InProcessHandle, type PluginModule, } from "./host-ctx.js"; +import { resolveRequirements } from "./service-registry.js"; export type PluginState = | "loaded" @@ -346,10 +347,25 @@ export class PluginLoader { ); } + // Structural only: is the service present at a satisfying version. A + // user's permission is checked per call instead, because activation is + // per-instance and permissions are per-user. + const resolution = resolveRequirements(plugin.manifest); + if (!resolution.satisfied) { + throw new Error(`Plugin ${plugin.id} ${resolution.errors.join("; ")}`); + } + for (const service of resolution.missingOptional) { + pluginLogger.info( + `Plugin ${plugin.id} optional service "${service}" is not available`, + { operation: "plugin_activate" }, + ); + } + const handle: InProcessHandle = { module: { activate, deactivate }, unsubscribers: [], providedKeys: [], + providedServices: [], }; const ctx = createInProcessContext(plugin.manifest, handle); diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index c9b09ed59..0ca8eb655 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -11,6 +11,7 @@ * runtime, for the same reason. If you change the schema, change both. */ +import semver from "semver"; import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js"; export const PLUGIN_PERMISSIONS = [ @@ -69,6 +70,8 @@ const ID_PATTERN = /^[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; const API_VERSION_PATTERN = /^[0-9]+$/; const API_PREFIX_PATTERN = /^[a-z0-9-]+$/; +/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */ +const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; /** * The plugin SDK major version this build implements. A manifest asking for a @@ -113,9 +116,31 @@ export interface PluginManifest { apiPrefix?: string; }; dependencies?: { plugins?: Record }; + /** + * Named services this plugin exposes to other plugins. Each one is gated by + * its own RBAC permission, which the plugin's own permissionGroup must + * declare -- see parseManifest. + */ + provides?: PluginServiceProvide[]; + /** Named services this plugin needs another active plugin to provide. */ + requires?: PluginServiceRequire[]; sidecars: Array<{ id: string; binary: string }>; } +export interface PluginServiceProvide { + service: string; + version: string; + /** The role permission a calling user needs to reach this service. */ + permission: string; +} + +export interface PluginServiceRequire { + service: string; + versionRange: string; + /** When true, an unsatisfied requirement is skipped instead of failing activation. */ + optional?: boolean; +} + export function validateManifest(manifest: unknown): string[] { const errors: string[] = []; @@ -209,6 +234,9 @@ export function validateManifest(manifest: unknown): string[] { errors.push('Field "sidecars" must be an array'); } + errors.push(...validateProvides(m.provides)); + errors.push(...validateRequires(m.requires)); + if (m.contributes && typeof m.contributes === "object") { errors.push( ...validateContributes(m.contributes as Record), @@ -218,6 +246,85 @@ export function validateManifest(manifest: unknown): string[] { return errors; } +function validateProvides(provides: unknown): string[] { + if (provides === undefined) return []; + if (!Array.isArray(provides)) return ['Field "provides" must be an array']; + + const errors: string[] = []; + const seen = new Set(); + + provides.forEach((entry: unknown, index: number) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`provides[${index}] must be an object`); + return; + } + const e = entry as Record; + + if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) { + errors.push( + `provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`, + ); + } else if (seen.has(e.service)) { + errors.push(`provides[${index}].service is a duplicate: "${e.service}"`); + } else { + seen.add(e.service); + } + + if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) { + errors.push( + `provides[${index}].version must be valid semver, got: "${String(e.version)}"`, + ); + } + + if (typeof e.permission !== "string" || e.permission.length === 0) { + errors.push(`provides[${index}].permission is required`); + } + }); + + return errors; +} + +function validateRequires(requires: unknown): string[] { + if (requires === undefined) return []; + if (!Array.isArray(requires)) return ['Field "requires" must be an array']; + + const errors: string[] = []; + const seen = new Set(); + + requires.forEach((entry: unknown, index: number) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`requires[${index}] must be an object`); + return; + } + const e = entry as Record; + + if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) { + errors.push( + `requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`, + ); + } else if (seen.has(e.service)) { + errors.push(`requires[${index}].service is a duplicate: "${e.service}"`); + } else { + seen.add(e.service); + } + + if ( + typeof e.versionRange !== "string" || + semver.validRange(e.versionRange) === null + ) { + errors.push( + `requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`, + ); + } + + if ("optional" in e && typeof e.optional !== "boolean") { + errors.push(`requires[${index}].optional must be a boolean`); + } + }); + + return errors; +} + function validateContributes(contributes: Record): string[] { const errors: string[] = []; @@ -331,6 +438,21 @@ export function parseManifest(raw: unknown): { }; } + // A service permission has to be one the plugin's own permissionGroup + // declares, or it would never reach the catalog and every call through it + // would deny against a permission no admin can grant. + const declaredPermissions = + manifest.contributes?.permissionGroup?.permissions ?? []; + for (const entry of manifest.provides ?? []) { + if (!declaredPermissions.includes(entry.permission)) { + return { + errors: [ + `Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, + ], + }; + } + } + // Refused outright rather than ignored: a plugin that asked to run in-process // and was quietly downgraded to a worker would fail later in a confusing way, // and the manifest would still claim reach it does not have. diff --git a/src/backend/plugins/service-registry.ts b/src/backend/plugins/service-registry.ts new file mode 100644 index 000000000..2d20f65d7 --- /dev/null +++ b/src/backend/plugins/service-registry.ts @@ -0,0 +1,414 @@ +/** + * Declared, versioned, per-user-permissioned service contracts between plugins. + * + * This is the typed sibling of registry.ts. That one is a bare key/value map a + * plugin can put anything into; this one only carries services a manifest + * declared, at a declared version, behind a declared role permission. + * + * ## What the permission actually gates + * + * Be precise, because there are two unrelated permission systems in this + * runtime and conflating them is the easy mistake: + * + * - permissions.ts gates what a PLUGIN may do (manifest + admin grant). + * - this file gates what a USER may do, through PermissionManager, the same + * RBAC check requirePermission() runs on an HTTP route. + * + * A service permission is an ordinary role permission string. It shows up in + * the admin role editor like hosts.view does, and an admin can grant or revoke + * it per role or per user at any time, whether or not the consuming plugin is + * installed. + * + * The check runs on every call rather than once when the handle is made. That + * is deliberate and is the whole reason the gate lives in the proxy: a revoke + * mid-session has to take effect on the next call, with no restart and no + * reinstall. PermissionManager's per-user cache is already invalidated by the + * RBAC routes that change a role, so "next call" is immediate in practice. + * + * ## Resolution is structural, permission is per-call + * + * resolveRequirements only asks "is this service present at a satisfying + * version". It deliberately does NOT consult any user's permissions: + * activation is per-instance and permissions are per-user, so gating + * activation on one user's grants would be meaningless. A user who lacks the + * permission gets a clear denial from the call instead of the requiring plugin + * mysteriously failing to start. + */ + +import semver from "semver"; +import { pluginLogger } from "../utils/logger.js"; +import type { PluginManifest, PluginServiceRequire } from "./manifest.js"; + +export interface ServiceRegistration { + service: string; + version: string; + permission: string; + /** The plugin that provided it. */ + pluginId: string; + /** Display name, for audit lines. */ + pluginName: string; + implementation: Record; + /** + * Bumped on every provide. A handle captures the value it was made against, + * so a provider that was revoked and re-registered invalidates old handles + * rather than silently rebinding them to a different object. + */ + generation: number; +} + +const services = new Map(); +/** + * What a service looked like before it was revoked. + * + * A handle taken while the provider was up must keep throwing a named error + * after it goes away, rather than degrading to "undefined is not a function", + * which would tell a consumer nothing about why its dependency vanished. + */ +const retired = new Map }>(); +let generationCounter = 0; + +/** Thrown when the provider went away while a consumer still held a handle. */ +export class PluginServiceUnavailableError extends Error { + readonly code = "EPLUGINSVCGONE"; + + constructor(service: string, providerPluginId: string) { + super( + `Service "${service}" is no longer provided (was: ${providerPluginId}). ` + + `The providing plugin has been disabled or replaced.`, + ); + this.name = "PluginServiceUnavailableError"; + } +} + +/** + * Thrown when the acting user lacks the service's permission. + * + * `status` and `body` are the same 403 shape requirePermission() sends, so a + * consumer serving an HTTP request can pass it straight through. + */ +export class PluginServicePermissionError extends Error { + readonly code = "EPLUGINSVCPERM"; + readonly status = 403; + readonly body: { error: string; required: string }; + + constructor(service: string, permission: string) { + super( + `You do not have access to "${service}". It requires the "${permission}" permission.`, + ); + this.name = "PluginServicePermissionError"; + this.body = { error: "Insufficient permissions", required: permission }; + } +} + +/** Thrown when a call cannot be attributed to a user. */ +export class PluginServiceActorError extends Error { + readonly code = "EPLUGINSVCACTOR"; + + constructor(service: string) { + super( + `A call to "${service}" has no acting user. Pass one with ` + + `ctx.services.get(name, { userId }) or handle.asUser(userId).`, + ); + this.name = "PluginServiceActorError"; + } +} + +export function provideService( + registration: Omit, +): ServiceRegistration { + const existing = services.get(registration.service); + if (existing) { + pluginLogger.warn( + `Service "${registration.service}" is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`, + { operation: "plugin_service_registry" }, + ); + } + + const entry: ServiceRegistration = { + ...registration, + generation: ++generationCounter, + }; + services.set(registration.service, entry); + retired.delete(registration.service); + return entry; +} + +/** + * Removes a provider, but only the exact registration given. A plugin that + * crashed and restarted must not revoke the replacement its own restart + * installed, so this is identity-checked rather than by name. + */ +export function revokeService( + service: string, + registration?: ServiceRegistration, +): boolean { + const current = services.get(service); + if (!current) return false; + if (registration && current.generation !== registration.generation) { + return false; + } + + retired.set(service, { + pluginId: current.pluginId, + methods: new Set( + Object.keys(current.implementation).filter( + (key) => typeof current.implementation[key] === "function", + ), + ), + }); + return services.delete(service); +} + +export function getRegistration( + service: string, +): ServiceRegistration | undefined { + return services.get(service); +} + +export function listServices(): ServiceRegistration[] { + return [...services.values()]; +} + +/** Test seam. */ +export function clearServiceRegistry(): void { + services.clear(); + retired.clear(); +} + +export interface RequirementResolution { + satisfied: boolean; + /** Human-readable reasons for each unsatisfied hard requirement. */ + errors: string[]; + /** Optional requirements that are not currently available. */ + missingOptional: string[]; +} + +/** + * Structural resolution of a manifest's `requires` against what is registered + * right now. Generic over the service name -- nothing is special-cased. + */ +export function resolveRequirements( + manifest: PluginManifest, +): RequirementResolution { + const errors: string[] = []; + const missingOptional: string[] = []; + + for (const requirement of manifest.requires ?? []) { + const registration = services.get(requirement.service); + + if (!registration) { + recordUnsatisfied( + requirement, + `requires service "${requirement.service}" (${requirement.versionRange}), which no active plugin provides`, + errors, + missingOptional, + ); + continue; + } + + if (!semver.satisfies(registration.version, requirement.versionRange)) { + recordUnsatisfied( + requirement, + `requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`, + errors, + missingOptional, + ); + } + } + + return { satisfied: errors.length === 0, errors, missingOptional }; +} + +function recordUnsatisfied( + requirement: PluginServiceRequire, + message: string, + errors: string[], + missingOptional: string[], +): void { + if (requirement.optional) { + missingOptional.push(requirement.service); + return; + } + errors.push(message); +} + +export interface ServiceCallContext { + /** Resolves the user this call acts as. */ + resolveUserId: () => string | undefined; + /** Checks a role permission for a user. */ + hasPermission: (userId: string, permission: string) => Promise; + /** + * Writes the audit line. Must never throw. + * + * Awaited before the call's own result is handed back, so an action is on + * the trail before the caller can act on it. The broker can afford to fire + * and forget because it replies over postMessage; here the caller is + * holding the promise. + */ + audit: (entry: ServiceAuditEntry) => void | Promise; +} + +export interface ServiceAuditEntry { + userId: string; + consumerPluginId: string; + registration: ServiceRegistration; + method: string; + args: unknown[]; + success: boolean; + errorMessage?: string; +} + +/** + * Wraps a registration in a handle whose every method runs the permission + * check before delegating. + * + * A Proxy rather than a hand-written wrapper so this stays generic over + * whatever shape the provider chose to hand out. Only own, callable properties + * of that object are reachable -- a consumer can call exactly what the + * provider exposed and nothing else. This is a set of individually declared + * contracts, not a general "call any method on any plugin" bus. + */ +export function createServiceHandle( + service: string, + consumerPluginId: string, + context: ServiceCallContext, +): T { + const target = Object.create(null) as T; + + return new Proxy(target, { + get(_target, property) { + if (typeof property !== "string") return undefined; + + if (property === "asUser") { + return (userId: string) => + createServiceHandle(service, consumerPluginId, { + ...context, + resolveUserId: () => userId, + }); + } + + // Resolved at call time, not here: the provider may have gone away + // between taking the handle and using it. A method that WAS reachable + // must keep throwing the typed error rather than degrading to + // `undefined is not a function`, which tells a consumer nothing about + // why its dependency vanished. + const registration = services.get(service); + if (!registration) { + const gone = retired.get(service); + if (!gone?.methods.has(property)) return undefined; + return () => { + throw new PluginServiceUnavailableError(service, gone.pluginId); + }; + } + + const value = registration.implementation[property]; + if (typeof value !== "function") return undefined; + + return (...args: unknown[]) => + invokeGuarded( + service, + property, + args, + consumerPluginId, + context, + registration.generation, + ); + }, + + has(_target, property) { + const registration = services.get(service); + if (!registration || typeof property !== "string") return false; + return typeof registration.implementation[property] === "function"; + }, + + ownKeys() { + const registration = services.get(service); + if (!registration) return []; + return Object.keys(registration.implementation).filter( + (key) => typeof registration.implementation[key] === "function", + ); + }, + + getOwnPropertyDescriptor(_target, property) { + const registration = services.get(service); + if (!registration || typeof property !== "string") return undefined; + if (typeof registration.implementation[property] !== "function") { + return undefined; + } + return { configurable: true, enumerable: true, writable: false }; + }, + }) as T; +} + +async function invokeGuarded( + service: string, + method: string, + args: unknown[], + consumerPluginId: string, + context: ServiceCallContext, + expectedGeneration: number, +): Promise { + const registration = services.get(service); + + // Re-checked here rather than trusted from the get trap: a call is async and + // the provider can be disabled between resolving the method and running it. + if (!registration || registration.generation !== expectedGeneration) { + throw new PluginServiceUnavailableError( + service, + registration?.pluginId ?? consumerPluginId, + ); + } + + const userId = context.resolveUserId(); + if (!userId) throw new PluginServiceActorError(service); + + const allowed = await context.hasPermission(userId, registration.permission); + if (!allowed) { + const error = new PluginServicePermissionError( + service, + registration.permission, + ); + await context.audit({ + userId, + consumerPluginId, + registration, + method, + args, + success: false, + errorMessage: error.message, + }); + throw error; + } + + const implementation = registration.implementation[method]; + if (typeof implementation !== "function") { + throw new PluginServiceUnavailableError(service, registration.pluginId); + } + + try { + const result = await ( + implementation as (...callArgs: unknown[]) => unknown + ).apply(registration.implementation, args); + + await context.audit({ + userId, + consumerPluginId, + registration, + method, + args, + success: true, + }); + return result; + } catch (error) { + await context.audit({ + userId, + consumerPluginId, + registration, + method, + args, + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 0bede6cd4..77568e63e 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -53,6 +53,103 @@ describe("plugin manifest validation", () => { ); }); + describe("provides / requires", () => { + const permissionGroup = { + group: "testplugin", + permissions: ["testplugin.greet.use"], + }; + + it("accepts a declared service and requirement", () => { + const errors = validateManifest( + validManifest({ + provides: [ + { + service: "testplugin.greet", + version: "1.0.0", + permission: "testplugin.greet.use", + }, + ], + requires: [ + { service: "other.thing", versionRange: "^1.0.0", optional: true }, + ], + contributes: { permissionGroup }, + }), + ); + expect(errors).toEqual([]); + }); + + it("rejects a service name that is not dotted", () => { + const errors = validateManifest( + validManifest({ + provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }], + }), + ); + expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true); + }); + + it("rejects a non-semver service version", () => { + const errors = validateManifest( + validManifest({ + provides: [{ service: "a.b", version: "1.0", permission: "x.y" }], + }), + ); + expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true); + }); + + it("rejects a duplicate provided service", () => { + const errors = validateManifest( + validManifest({ + provides: [ + { service: "a.b", version: "1.0.0", permission: "x.y" }, + { service: "a.b", version: "2.0.0", permission: "x.y" }, + ], + }), + ); + expect(errors.some((e) => e.includes("duplicate"))).toBe(true); + }); + + it("rejects an invalid semver range", () => { + const errors = validateManifest( + validManifest({ + requires: [{ service: "a.b", versionRange: "not a range" }], + }), + ); + expect(errors.some((e) => e.includes("versionRange"))).toBe(true); + }); + + it("rejects a non-boolean optional", () => { + const errors = validateManifest( + validManifest({ + requires: [ + { service: "a.b", versionRange: "^1.0.0", optional: "yes" }, + ], + }), + ); + expect(errors.some((e) => e.includes("optional"))).toBe(true); + }); + + it("refuses a service gated by a permission the group never declares", () => { + // Otherwise every call through it would deny against a permission no + // admin could ever grant. + const { manifest, errors } = parseManifest( + validManifest({ + provides: [ + { + service: "testplugin.greet", + version: "1.0.0", + permission: "testplugin.undeclared", + }, + ], + contributes: { permissionGroup }, + }), + ); + + expect(manifest).toBeUndefined(); + expect(errors[0]).toContain("testplugin.undeclared"); + expect(errors[0]).toContain("permissionGroup"); + }); + }); + it("rejects an unknown permission", () => { const errors = validateManifest( validManifest({ permissions: ["credentials.read"] }), diff --git a/src/backend/tests/plugins/service-contract.test.ts b/src/backend/tests/plugins/service-contract.test.ts new file mode 100644 index 000000000..3f24bffda --- /dev/null +++ b/src/backend/tests/plugins/service-contract.test.ts @@ -0,0 +1,342 @@ +/** + * Two real plugins on disk, one providing a service and one requiring it, + * driven through the actual PluginLoader. + * + * The ids are the real first-party ones because the in-process allowlist is + * hardcoded and deliberately unfakeable -- the same thing in-process-loader + * does. The service ("testplugin.greet") and the permission gating it + * ("testplugin.greet.use") are fixture-owned, so nothing about the shipped + * ssh-terminal or docker plugins is under test here; only their ids are + * borrowed to reach the in-process tier. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import type { Fixture } from "./fixture-plugin.js"; + +const state = vi.hoisted(() => ({ + /** Permissions the acting user currently holds. Mutated mid-test. */ + granted: new Set(), + auditEntries: [] as Record[], + /** Records every delegated call, to prove the gate runs before the provider. */ + providerCalls: [] as string[], +})); + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: vi.fn(async (params: Record) => { + state.auditEntries.push(params); + }), + getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), +})); + +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (_userId: string, permission: string) => + state.granted.has(permission), + invalidateUserPermissionCache: vi.fn(), + }), + }, +})); + +const { PluginLoader } = await import("../../plugins/loader.js"); +const { TRANSPORT_OWNER_CAPABILITY } = + await import("../../plugins/first-party.js"); +const { + clearServiceRegistry, + getRegistration, + PluginServicePermissionError, + PluginServiceUnavailableError, +} = await import("../../plugins/service-registry.js"); +const { pluginEvents } = await import("../../plugins/events.js"); +const { createFixturePlugin } = await import("./fixture-plugin.js"); + +const SERVICE = "testplugin.greet"; +const PERMISSION = "testplugin.greet.use"; + +/** + * The provider publishes a greet service and records every delegated call to + * a file, so the test can prove a denied call never reached it. A module-level + * variable would not work: the plugin is imported by URL into this process and + * lives in its own ESM module instance. + */ +function providerSource(callLog: string): string { + const literal = JSON.stringify(callLog); + return ` +import fs from "node:fs"; +export async function activate(ctx) { + ctx.services.provide(${JSON.stringify(SERVICE)}, { + hello: async (name) => { + fs.appendFileSync(${literal}, name + "\\n"); + return "hello " + name; + }, + }); +} +`; +} + +/** + * The consumer takes a handle at activation and stashes it on globalThis so + * the test can call it as a specific user afterwards. + */ +const CONSUMER_SOURCE = ` +export async function activate(ctx) { + globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)}); +} +`; + +function providerFixture(): Fixture { + return createFixturePlugin({ + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + manifestOverrides: { + category: "Terminal", + provides: [ + { service: SERVICE, version: "1.2.0", permission: PERMISSION }, + ], + contributes: { + permissionGroup: { group: "testplugin", permissions: [PERMISSION] }, + }, + }, + }); +} + +function consumerFixture( + requires: Array> = [ + { service: SERVICE, versionRange: "^1.2.0" }, + ], +): Fixture { + return createFixturePlugin({ + id: "docker", + permissions: [TRANSPORT_OWNER_CAPABILITY], + backendSource: CONSUMER_SOURCE, + manifestOverrides: { category: "Infrastructure", requires }, + }); +} + +type GreetHandle = { + hello: (name: string) => Promise; + asUser: (userId: string) => { hello: (name: string) => Promise }; +}; + +function handle(): GreetHandle { + return (globalThis as Record) + .__consumerHandle as GreetHandle; +} + +describe("plugin service contracts", () => { + let provider: Fixture | null = null; + let consumer: Fixture | null = null; + let loader: InstanceType | null = null; + let callLog = ""; + + beforeEach(() => { + state.granted = new Set([PERMISSION]); + state.auditEntries = []; + state.providerCalls = []; + clearServiceRegistry(); + pluginEvents.clear(); + }); + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + provider?.cleanup(); + consumer?.cleanup(); + provider = null; + consumer = null; + delete (globalThis as Record).__consumerHandle; + clearServiceRegistry(); + pluginEvents.clear(); + }); + + async function activateBoth( + consumerRequires?: Array>, + ) { + provider = providerFixture(); + callLog = path.join(provider.root, "calls.txt"); + fs.writeFileSync( + path.join(provider.dir, "backend", "index.mjs"), + providerSource(callLog), + ); + + consumer = consumerRequires + ? consumerFixture(consumerRequires) + : consumerFixture(); + + loader = new PluginLoader(); + const providerPlugin = await loader.load(provider.dir); + await loader.activate(providerPlugin.id); + + const consumerPlugin = await loader.load(consumer.dir); + await loader.activate(consumerPlugin.id); + + return { providerPlugin, consumerPlugin }; + } + + function providerCallCount(): number { + if (!fs.existsSync(callLog)) return 0; + return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean) + .length; + } + + it("resolves structurally when both plugins are active", async () => { + const { providerPlugin, consumerPlugin } = await activateBoth(); + + expect(providerPlugin.state).toBe("active"); + expect(consumerPlugin.state).toBe("active"); + + const registration = getRegistration(SERVICE); + expect(registration?.version).toBe("1.2.0"); + expect(registration?.pluginId).toBe("ssh-terminal"); + expect(registration?.permission).toBe(PERMISSION); + }); + + it("fails activation when a hard requirement is unsatisfied", async () => { + consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]); + + loader = new PluginLoader(); + const plugin = await loader.load(consumer.dir); + + await expect(loader.activate(plugin.id)).rejects.toThrow( + /no active plugin provides|provides 1\.2\.0/, + ); + expect(plugin.state).toBe("crashed"); + }); + + it("activates anyway when an unsatisfied requirement is optional", async () => { + consumer = consumerFixture([ + { service: "testplugin.absent", versionRange: "^1.0.0", optional: true }, + ]); + + loader = new PluginLoader(); + const plugin = await loader.load(consumer.dir); + await loader.activate(plugin.id); + + expect(plugin.state).toBe("active"); + }); + + it("allows a call from a user who holds the permission", async () => { + await activateBoth(); + + await expect(handle().asUser("alice").hello("world")).resolves.toBe( + "hello world", + ); + expect(providerCallCount()).toBe(1); + + const entry = state.auditEntries.at(-1); + expect(entry?.success).toBe(true); + expect(entry?.username).toBe("plugin:docker"); + expect(entry?.userId).toBe("alice"); + expect(entry?.action).toBe("plugin_service_testplugin_greet_hello"); + expect(entry?.resourceId).toBe("ssh-terminal"); + }); + + it("denies a user without the permission, with the standard shape", async () => { + await activateBoth(); + state.granted.delete(PERMISSION); + + await handle() + .asUser("mallory") + .hello("world") + .then( + () => expect.unreachable("should have been denied"), + (error: InstanceType) => { + expect(error.name).toBe("PluginServicePermissionError"); + expect(error.status).toBe(403); + expect(error.body).toEqual({ + error: "Insufficient permissions", + required: PERMISSION, + }); + expect(error.message).toContain("do not have access"); + }, + ); + + // The gate runs before delegation: the provider never saw the call. + expect(providerCallCount()).toBe(0); + + const entry = state.auditEntries.at(-1); + expect(entry?.success).toBe(false); + expect(entry?.userId).toBe("mallory"); + expect(String(entry?.errorMessage)).toContain(PERMISSION); + }); + + it("honours a revoke mid-session on the very next call", async () => { + await activateBoth(); + const live = handle().asUser("alice"); + + await expect(live.hello("first")).resolves.toBe("hello first"); + + // No restart, no reinstall, same handle: the check is per call, which is + // exactly why it lives in the proxy rather than at handle creation. + state.granted.delete(PERMISSION); + + await expect(live.hello("second")).rejects.toThrow( + PluginServicePermissionError, + ); + expect(providerCallCount()).toBe(1); + + // And granting it back takes effect just as immediately. + state.granted.add(PERMISSION); + await expect(live.hello("third")).resolves.toBe("hello third"); + expect(providerCallCount()).toBe(2); + }); + + it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => { + const { consumerPlugin } = await activateBoth(); + const live = handle().asUser("alice"); + + await expect(live.hello("before")).resolves.toBe("hello before"); + + await loader!.deactivate("ssh-terminal"); + + expect(getRegistration(SERVICE)).toBeUndefined(); + + // Documented behaviour: the consumer stays active and decides for itself + // what to do. Deactivating one plugin must not silently stop others. + expect(consumerPlugin.state).toBe("active"); + + let thrown: unknown; + try { + await live.hello("after"); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(PluginServiceUnavailableError); + expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE"); + expect((thrown as Error).message).toContain(SERVICE); + expect(providerCallCount()).toBe(1); + }); + + it("refuses to provide a service the manifest never declared", async () => { + provider = createFixturePlugin({ + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + backendSource: ` +export async function activate(ctx) { + ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" }); +} +`, + manifestOverrides: { category: "Terminal" }, + }); + + loader = new PluginLoader(); + const plugin = await loader.load(provider.dir); + + await expect(loader.activate(plugin.id)).rejects.toThrow( + /not declared in the manifest's provides array/, + ); + }); +}); diff --git a/src/backend/tests/plugins/service-registry.test.ts b/src/backend/tests/plugins/service-registry.test.ts new file mode 100644 index 000000000..74215bf58 --- /dev/null +++ b/src/backend/tests/plugins/service-registry.test.ts @@ -0,0 +1,284 @@ +/** + * Unit coverage for the declared service registry: registration, versioned + * structural resolution, and the per-call permission gate on a handle. + * + * The two-fixture end-to-end test lives in service-contract.test.ts. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const { + clearServiceRegistry, + createServiceHandle, + getRegistration, + listServices, + provideService, + resolveRequirements, + revokeService, + PluginServiceActorError, + PluginServicePermissionError, + PluginServiceUnavailableError, +} = await import("../../plugins/service-registry.js"); + +type Registration = ReturnType; + +function register( + overrides: Partial[0]> = {}, +): Registration { + return provideService({ + service: "testplugin.greet", + version: "1.0.0", + permission: "testplugin.greet.use", + pluginId: "provider-plugin", + pluginName: "Provider Plugin", + implementation: { hello: async (name: string) => `hello ${name}` }, + ...overrides, + }); +} + +function manifestRequiring( + requires: Array<{ + service: string; + versionRange: string; + optional?: boolean; + }>, +) { + return { id: "consumer-plugin", requires } as never; +} + +describe("service registry", () => { + beforeEach(() => clearServiceRegistry()); + afterEach(() => clearServiceRegistry()); + + it("round-trips a registration", () => { + const registration = register(); + + expect(getRegistration("testplugin.greet")).toBe(registration); + expect(listServices()).toHaveLength(1); + expect(registration.generation).toBeGreaterThan(0); + }); + + it("only revokes the registration that is actually current", () => { + const first = register(); + const second = register(); + + // A crashed-and-restarted provider must not revoke the replacement its own + // restart installed. + expect(revokeService("testplugin.greet", first)).toBe(false); + expect(getRegistration("testplugin.greet")).toBe(second); + + expect(revokeService("testplugin.greet", second)).toBe(true); + expect(getRegistration("testplugin.greet")).toBeUndefined(); + }); + + it("reports a revoke of something never registered", () => { + expect(revokeService("nothing.here")).toBe(false); + }); + + describe("resolution", () => { + it("is satisfied when the version is in range", () => { + register({ version: "1.2.0" }); + + const resolution = resolveRequirements( + manifestRequiring([ + { service: "testplugin.greet", versionRange: "^1.2.0" }, + ]), + ); + + expect(resolution.satisfied).toBe(true); + expect(resolution.errors).toEqual([]); + }); + + it("fails a hard requirement nothing provides", () => { + const resolution = resolveRequirements( + manifestRequiring([ + { service: "testplugin.greet", versionRange: "^1.0.0" }, + ]), + ); + + expect(resolution.satisfied).toBe(false); + expect(resolution.errors[0]).toContain("no active plugin provides"); + }); + + it("fails when the provided version is out of range", () => { + register({ version: "2.0.0" }); + + const resolution = resolveRequirements( + manifestRequiring([ + { service: "testplugin.greet", versionRange: "^1.0.0" }, + ]), + ); + + expect(resolution.satisfied).toBe(false); + expect(resolution.errors[0]).toContain("provides 2.0.0"); + }); + + it("skips an unsatisfied optional requirement", () => { + const resolution = resolveRequirements( + manifestRequiring([ + { + service: "testplugin.greet", + versionRange: "^1.0.0", + optional: true, + }, + ]), + ); + + expect(resolution.satisfied).toBe(true); + expect(resolution.missingOptional).toEqual(["testplugin.greet"]); + }); + + it("treats a manifest with no requires as satisfied", () => { + expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true); + }); + }); + + describe("handle", () => { + function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) { + const userId = actor.length === 0 ? "user-1" : actor[0]; + const audits: Array> = []; + const handle = createServiceHandle<{ + hello: (name: string) => Promise; + }>("testplugin.greet", "consumer-plugin", { + resolveUserId: () => userId, + hasPermission: async () => allowed, + audit: (entry) => void audits.push(entry as never), + }); + return { handle, audits }; + } + + it("delegates to the implementation when permitted", async () => { + register(); + const { handle, audits } = handleFor(true); + + await expect(handle.hello("world")).resolves.toBe("hello world"); + expect(audits).toHaveLength(1); + expect(audits[0].success).toBe(true); + }); + + it("denies without the permission and never reaches the provider", async () => { + const hello = vi.fn(async () => "should not run"); + register({ implementation: { hello } }); + const { handle, audits } = handleFor(false); + + await expect(handle.hello("world")).rejects.toThrow( + PluginServicePermissionError, + ); + // The gate has to run before delegation, not after. + expect(hello).not.toHaveBeenCalled(); + expect(audits[0].success).toBe(false); + }); + + it("denies with the same 403 shape requirePermission sends", async () => { + register(); + const { handle } = handleFor(false); + + await handle.hello("world").then( + () => expect.unreachable("should have denied"), + (error: InstanceType) => { + expect(error.status).toBe(403); + expect(error.body).toEqual({ + error: "Insufficient permissions", + required: "testplugin.greet.use", + }); + }, + ); + }); + + it("refuses a call it cannot attribute to a user", async () => { + register(); + const { handle } = handleFor(true, undefined); + + await expect(handle.hello("world")).rejects.toThrow( + PluginServiceActorError, + ); + }); + + it("throws once the provider is revoked", async () => { + const registration = register(); + const { handle } = handleFor(true); + + await expect(handle.hello("world")).resolves.toBe("hello world"); + + revokeService("testplugin.greet", registration); + + expect(() => handle.hello("world")).toThrow( + /no longer provided|is not a function/, + ); + }); + + it("exposes only the functions the provider handed out", () => { + register({ + implementation: { + hello: async () => "hi", + secret: "not a function", + }, + }); + const { handle } = handleFor(true); + + expect(typeof handle.hello).toBe("function"); + expect( + (handle as unknown as Record).secret, + ).toBeUndefined(); + expect(Object.keys(handle)).toEqual(["hello"]); + }); + + it("rebinds the acting user with asUser", async () => { + register(); + const seen: string[] = []; + const handle = createServiceHandle<{ + hello: (name: string) => Promise; + asUser: (userId: string) => { hello: (n: string) => Promise }; + }>("testplugin.greet", "consumer-plugin", { + resolveUserId: () => undefined, + hasPermission: async (userId) => { + seen.push(userId); + return true; + }, + audit: () => {}, + }); + + await handle.asUser("user-42").hello("world"); + expect(seen).toEqual(["user-42"]); + }); + + it("surfaces an unavailable service rather than a generic undefined", () => { + const { handle } = handleFor(true); + expect( + (handle as unknown as Record).hello, + ).toBeUndefined(); + }); + + it("audits a failure thrown by the provider itself", async () => { + register({ + implementation: { + hello: async () => { + throw new Error("provider exploded"); + }, + }, + }); + const { handle, audits } = handleFor(true); + + await expect(handle.hello("world")).rejects.toThrow("provider exploded"); + expect(audits[0].success).toBe(false); + expect(audits[0].errorMessage).toBe("provider exploded"); + }); + }); + + it("exposes the unavailable error with a stable code", () => { + const error = new PluginServiceUnavailableError("a.b", "provider"); + expect(error.code).toBe("EPLUGINSVCGONE"); + expect(error.message).toContain("a.b"); + expect(error.message).toContain("provider"); + }); +});