From a2e134d4982550f9f7536e01dac5e56a5f2b5baf Mon Sep 17 00:00:00 2001 From: Neko Date: Thu, 19 Mar 2026 23:50:04 +0800 Subject: [PATCH] feat(plugin-sdk): much better refactor, and new permission model (#1423) Authored-by-agent: Codex <267193182+Codex@users.noreply.github.com> Co-authored-by-agent: Claude Opus 4.6 --- .../examples/devtools-sample-plugin/README.md | 2 + .../devtools-sample-plugin.json | 24 + .../main/services/airi/plugins/index.test.ts | 139 +++- .../src/main/services/airi/plugins/index.ts | 89 ++- packages/plugin-protocol/src/types/events.ts | 176 ++++- packages/plugin-sdk/package.json | 1 + .../plugin-sdk/src/plugin-host/core.test.ts | 357 ++++++++- packages/plugin-sdk/src/plugin-host/core.ts | 700 ++++++++++-------- packages/plugin-sdk/src/plugin-host/index.ts | 3 + .../src/plugin-host/runtimes/node/index.ts | 2 + .../plugin-host/runtimes/node/loaders/fs.ts | 92 +++ .../runtimes/node/loaders/index.ts | 1 + .../src/plugin-host/runtimes/shared/index.ts | 1 + .../shared/services/dependencies.test.ts | 116 +++ .../runtimes/shared/services/dependencies.ts | 108 +++ .../runtimes/shared/services/index.ts | 4 + .../shared/services/permissions.test.ts | 211 ++++++ .../runtimes/shared/services/permissions.ts | 331 +++++++++ .../shared/services/resources.test.ts | 41 + .../runtimes/shared/services/resources.ts | 93 +++ .../runtimes/shared/services/sessions.test.ts | 65 ++ .../runtimes/shared/services/sessions.ts | 54 ++ .../src/plugin-host/runtimes/web/index.ts | 1 + packages/plugin-sdk/src/plugin-host/shared.ts | 1 - .../src/plugin-host/shared/index.ts | 1 + .../src/plugin-host/shared/types.ts | 151 ++++ .../apis/protocol/capabilities/index.ts | 6 +- pnpm-lock.yaml | 3 + 28 files changed, 2433 insertions(+), 340 deletions(-) create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/index.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/index.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.test.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.test.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts create mode 100644 packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts delete mode 100644 packages/plugin-sdk/src/plugin-host/shared.ts create mode 100644 packages/plugin-sdk/src/plugin-host/shared/index.ts create mode 100644 packages/plugin-sdk/src/plugin-host/shared/types.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md index 89b619116..73bfa596a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md @@ -7,6 +7,8 @@ This sample plugin is for validating plugin host behavior in the **Plugin Host I - `devtools-sample-plugin.json`: plugin manifest (`ManifestV1`) - `devtools-sample-plugin.mjs`: plugin implementation +The manifest declares the protocol permissions required by `apis.providers.listProviders()`: invoke `capabilities:wait`, invoke `resources:providers:list-providers`, read the provider resource, and wait for the provider-list capability. + ## How to use 1. Open `/devtools/plugin-host` in Stage Tamagotchi. diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json index 2742c7368..51514d400 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json @@ -2,6 +2,30 @@ "apiVersion": "v1", "kind": "manifest.plugin.airi.moeru.ai", "name": "devtools-sample-plugin", + "permissions": { + "apis": [ + { + "key": "proj-airi:plugin-sdk:apis:protocol:capabilities:wait", + "actions": ["invoke"] + }, + { + "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", + "actions": ["invoke"] + } + ], + "resources": [ + { + "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", + "actions": ["read"] + } + ], + "capabilities": [ + { + "key": "proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers", + "actions": ["wait"] + } + ] + }, "entrypoints": { "electron": "./devtools-sample-plugin.mjs" } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 698f5431a..5e997ac5a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -7,7 +7,13 @@ import { basename, join, resolve } from 'node:path' import { defineInvoke } from '@moeru/eventa' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { electronPluginList, electronPluginLoadEnabled, electronPluginSetEnabled } from '../../../../shared/eventa' +import { + electronPluginInspect, + electronPluginList, + electronPluginLoadEnabled, + electronPluginSetEnabled, + electronPluginUpdateCapability, +} from '../../../../shared/eventa' import { setupPluginHost } from './index' const appMock = vi.hoisted(() => ({ @@ -48,6 +54,11 @@ const testDataRoot = resolve( 'plugin-host', 'testdata', ) +const samplePluginRoot = resolve( + import.meta.dirname, + 'examples', + 'devtools-sample-plugin', +) async function writeManifest(params: { dir: string, name: string, entrypoint: string }) { const manifest = { @@ -85,6 +96,12 @@ async function copyEntrypoint(params: { dir: string, path: string }) { return file } +async function writeEntrypoint(params: { dir: string, name: string, contents: string }) { + const destination = join(params.dir, params.name) + await writeFile(destination, params.contents) + return destination +} + describe('setupPluginHost', () => { let userDataDir: string let pluginsDir: string @@ -167,14 +184,21 @@ describe('setupPluginHost', () => { }) it('loads enabled plugins and keeps failed plugins unloaded', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - await writeManifestInPluginDir({ - rootDir: pluginsDir, - pluginDirName: 'test-normal', - pluginName: 'test-normal', - entrypointPath: normalEntrypoint, + const successPluginDir = join(pluginsDir, 'test-normal') + await mkdir(successPluginDir, { recursive: true }) + await writeEntrypoint({ + dir: successPluginDir, + name: 'test-normal-plugin.ts', + contents: [ + 'export async function init() {}', + ].join('\n'), + }) + await writeManifest({ + dir: successPluginDir, + name: 'test-normal', + entrypoint: './test-normal-plugin.ts', }) await writeManifestInPluginDir({ rootDir: pluginsDir, @@ -200,4 +224,105 @@ describe('setupPluginHost', () => { expect(normal).toEqual(expect.objectContaining({ enabled: true, loaded: true })) expect(error).toEqual(expect.objectContaining({ enabled: true, loaded: false })) }) + + it('loads enabled plugins with absolute manifest entrypoints outside the plugin directory', async () => { + const externalDir = await mkdtemp(join(tmpdir(), 'airi-plugin-external-')) + + try { + const pluginDir = join(pluginsDir, 'test-absolute-entrypoint') + await mkdir(pluginDir, { recursive: true }) + const externalEntrypoint = await writeEntrypoint({ + dir: externalDir, + name: 'test-absolute-plugin.ts', + contents: [ + 'export async function init() {}', + ].join('\n'), + }) + await writeManifest({ + dir: pluginDir, + name: 'test-absolute-entrypoint', + entrypoint: externalEntrypoint, + }) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + + await invokeSetEnabled({ name: 'test-absolute-entrypoint', enabled: true }) + + const snapshot = await invokeLoadEnabled() + const plugin = snapshot.plugins.find(item => item.name === 'test-absolute-entrypoint') + + expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) + } + finally { + await rm(externalDir, { recursive: true, force: true }) + } + }) + + it('loads the devtools sample plugin with its declared protocol permissions', async () => { + const pluginDir = join(pluginsDir, 'devtools-sample-plugin') + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, 'devtools-sample-plugin.json'), + await readFile(join(samplePluginRoot, 'devtools-sample-plugin.json'), 'utf-8'), + ) + await writeFile( + join(pluginDir, 'devtools-sample-plugin.mjs'), + await readFile(join(samplePluginRoot, 'devtools-sample-plugin.mjs'), 'utf-8'), + ) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + + await invokeSetEnabled({ name: 'devtools-sample-plugin', enabled: true }) + + const snapshot = await invokeLoadEnabled() + const plugin = snapshot.plugins.find(item => item.name === 'devtools-sample-plugin') + + expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) + }) + + it('mirrors degraded and withdrawn capability updates into the host snapshot', async () => { + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) + const invokeUpdateCapability = defineInvoke(contextState.lastContext!, electronPluginUpdateCapability) + + await invokeUpdateCapability({ + key: 'cap:renderer-status', + state: 'degraded', + metadata: { reason: 'renderer-restarting' }, + }) + + let snapshot = await invokeInspect() + expect(snapshot.capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + key: 'cap:renderer-status', + state: 'degraded', + metadata: { reason: 'renderer-restarting' }, + }), + ])) + + await invokeUpdateCapability({ + key: 'cap:renderer-status', + state: 'withdrawn', + metadata: { reason: 'renderer-unmounted' }, + }) + + snapshot = await invokeInspect() + expect(snapshot.capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + key: 'cap:renderer-status', + state: 'withdrawn', + metadata: { reason: 'renderer-unmounted' }, + }), + ])) + }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index 080dc2ad6..cb12de552 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -1,3 +1,5 @@ +import type { Dirent } from 'node:fs' + import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' import type { @@ -35,7 +37,7 @@ interface PluginHostService { } interface CapabilityAwarePluginHost extends PluginHost { - setProvidersListResolver: (resolver: () => Promise> | Array<{ name: string }>) => void + setResourceResolver: (key: string, resolver: () => Promise | T) => void announceCapability: (key: string, metadata?: Record) => { key: string state: 'announced' | 'ready' | 'degraded' | 'withdrawn' @@ -48,6 +50,18 @@ interface CapabilityAwarePluginHost extends PluginHost { metadata?: Record updatedAt: number } + markCapabilityDegraded: (key: string, metadata?: Record) => { + key: string + state: 'announced' | 'ready' | 'degraded' | 'withdrawn' + metadata?: Record + updatedAt: number + } + withdrawCapability: (key: string, metadata?: Record) => { + key: string + state: 'announced' | 'ready' | 'degraded' | 'withdrawn' + metadata?: Record + updatedAt: number + } } interface PluginConfig { @@ -71,6 +85,26 @@ function isManifestV1(value: unknown): value is ManifestV1 { return safeParse(manifestV1Schema, value).success } +async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { + if (!entry.isSymbolicLink()) { + return { resolved: false } + } + + try { + const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) + + const stats = await stat(resolvedPath) + if (stats.isFile() || stats.isDirectory()) { + return { resolved: true, path: resolvedPath } + } + + return { resolved: false } + } + catch (error) { + return { resolved: false, error } + } +} + async function loadManifestsFrom(dir: string, log: ReturnType): Promise { await mkdir(dir, { recursive: true }) const entries = await readdir(dir, { withFileTypes: true }) @@ -78,10 +112,35 @@ async function loadManifestsFrom(dir: string, log: ReturnType): const manifestPaths: string[] = [] for (const entry of entries) { - if (!entry.isDirectory()) - continue + if (!entry.isDirectory()) { + if (entry.isSymbolicLink()) { + const { resolved, error } = await realPathOf(entry, { cwd: dir }) + if (error) { + log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping') + continue + } + if (!resolved) { + log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') + continue + } + } + else { + continue + } + } + + let pluginDir = join(dir, entry.name) + if (entry.isSymbolicLink()) { + const { path, resolved } = await realPathOf(entry, { cwd: dir }) + if (resolved) { + pluginDir = path + } + else { + log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') + continue + } + } - const pluginDir = join(dir, entry.name) const pluginEntries = await readdir(pluginDir, { withFileTypes: true }) for (const pluginEntry of pluginEntries) { if (pluginEntry.isSymbolicLink()) { @@ -334,14 +393,26 @@ export async function setupPluginHost(): Promise { defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => { if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') { - capabilityHost.setProvidersListResolver(async () => await invokePluginProtocolListProviders()) + capabilityHost.setResourceResolver( + pluginProtocolListProvidersEventName, + async () => await invokePluginProtocolListProviders(), + ) } - if (payload.state === 'announced') { - return capabilityHost.announceCapability(payload.key, payload.metadata) + switch (payload.state) { + case 'announced': + return capabilityHost.announceCapability(payload.key, payload.metadata) + case 'ready': + return capabilityHost.markCapabilityReady(payload.key, payload.metadata) + case 'degraded': + return capabilityHost.markCapabilityDegraded(payload.key, payload.metadata) + case 'withdrawn': + return capabilityHost.withdrawCapability(payload.key, payload.metadata) + default: { + const unexpectedState: never = payload.state + throw new Error(`Unsupported capability state: ${unexpectedState}`) + } } - - return capabilityHost.markCapabilityReady(payload.key, payload.metadata) }) // Initialize enabled plugins during module setup so startup is bound to injeca lifecycle. diff --git a/packages/plugin-protocol/src/types/events.ts b/packages/plugin-protocol/src/types/events.ts index 9692017b9..81216ff45 100644 --- a/packages/plugin-protocol/src/types/events.ts +++ b/packages/plugin-protocol/src/types/events.ts @@ -343,6 +343,57 @@ export interface ModuleCapability { metadata?: Record } +export type ModulePermissionArea = 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines' + +export interface ModulePermissionSpec< + Area extends ModulePermissionArea = ModulePermissionArea, + Action extends string = string, +> { + key: string + actions: Action[] + /** + * Human-facing explanation for consent/permission UI. + * Prefer i18n key form over raw strings for localization. + */ + reason?: Localizable + /** + * Optional short display label for permission prompts. + * Prefer i18n key form over raw strings for localization. + */ + label?: Localizable + required?: boolean + metadata?: Record + area?: Area +} + +export interface ModulePermissionDeclaration { + apis?: ModulePermissionSpec<'apis', 'invoke' | 'emit'>[] + resources?: ModulePermissionSpec<'resources', 'read' | 'write' | 'subscribe'>[] + capabilities?: ModulePermissionSpec<'capabilities', 'wait' | 'snapshot'>[] + processors?: ModulePermissionSpec<'processors', 'register' | 'execute' | 'manage'>[] + pipelines?: ModulePermissionSpec<'pipelines', 'hook' | 'process' | 'emit' | 'manage'>[] +} + +export type ModulePermissionGrant = ModulePermissionDeclaration + +/** + * Describes a single authorization failure produced by host-side permission checks. + * + * Protocol expectations: + * - `area`, `action`, and `key` identify the denied operation + * - `reason` is intended for user-facing or diagnostic context and may be localized + * - `recoverable` indicates whether the caller may reasonably retry after obtaining consent, + * reconfiguration, or a state change + * - plugins should not treat `reason` as a stable machine-readable code + */ +export interface ModulePermissionError { + area: ModulePermissionArea + action: string + key: string + reason?: Localizable + recoverable?: boolean +} + export type RouteTargetExpression = | { type: 'and', all: RouteTargetExpression[] } | { type: 'or', any: RouteTargetExpression[] } @@ -535,14 +586,19 @@ interface ErrorEvent { message: string } +interface ErrorPermissionEvent { + identity?: ModuleIdentity + error: ModulePermissionError +} + interface ModuleAnnounceEvent { name: string identity: ModuleIdentity possibleEvents: Array<(keyof ProtocolEvents)> + permissions?: ModulePermissionDeclaration configSchema?: ModuleConfigSchema dependencies?: ModuleDependency[] } - interface ModuleAnnouncedEvent { name: string index?: number @@ -567,6 +623,104 @@ interface RegistryModulesHealthHealthyEvent { name: string index?: number identity: ModuleIdentity + +} + +/** + * Emitted when a module declares the permissions it may need. + * + * Typical use cases: + * - manifest-time declaration for installation, review, and audit surfaces + * - runtime declaration when a module can only discover optional integrations later + * + * Protocol expectations: + * - this event communicates intent only and does not grant access + * - hosts may record, display, audit, or validate this declaration before any request is approved + * - plugins must not assume any declared permission is usable until it appears in current grants + * - `source` indicates whether the declaration originated from static manifest data or runtime code + */ +interface ModulePermissionsDeclareEvent { + identity: ModuleIdentity + requested: ModulePermissionDeclaration + source: 'manifest' | 'runtime' +} + +/** + * Emitted when a module actively asks the host to approve some or all declared permissions. + * + * Typical use cases: + * - deferred consent before first use of a sensitive API or resource + * - requesting optional capabilities only when a feature is enabled by the user + * + * Protocol expectations: + * - hosts may prompt the user, auto-approve, partially approve, or deny the request + * - plugins must treat this as a request for evaluation, not as confirmation of access + * - plugins should provide a user-facing `reason` when approval UX needs explanatory context + * - the host response may later be expressed through granted, denied, and current permission events + */ +interface ModulePermissionsRequestEvent { + identity: ModuleIdentity + requested: ModulePermissionDeclaration + reason?: string +} + +/** + * Emitted after the host approves additional permissions for a module. + * + * Typical use cases: + * - notifying the runtime that a previous permission request succeeded + * - allowing plugin code to resume or unlock gated features + * + * Protocol expectations: + * - `granted` may be narrower than the corresponding request + * - plugins must inspect the granted payload instead of assuming the full request was approved + * - `revision` increments when the permission snapshot changes and may be used to invalidate cached state + * - hosts may emit this event before or together with an updated current snapshot + */ +interface ModulePermissionsGrantedEvent { + identity: ModuleIdentity + granted: ModulePermissionGrant + revision: number +} + +/** + * Emitted when some requested permissions are rejected or remain unavailable. + * + * Typical use cases: + * - surfacing partial denials after a consent flow + * - explaining why a feature must stay disabled or degraded + * + * Protocol expectations: + * - `denied` describes the requested permissions that are not available after evaluation + * - plugins must handle denial gracefully and should provide fallback behavior when feasible + * - `reason` is intended for diagnostics or UX context and should not be treated as a stable machine-readable code + * - `revision` identifies the permission-state version associated with this denial result + */ +interface ModulePermissionsDeniedEvent { + identity: ModuleIdentity + denied: ModulePermissionDeclaration + reason?: string + revision: number +} + +/** + * Emitted with the module's reconciled current permission snapshot. + * + * Typical use cases: + * - bootstrapping plugin runtime state after startup or reload + * - synchronizing UI/debug tools with the final requested vs granted view + * + * Protocol expectations: + * - this is the authoritative event for "what is currently allowed" + * - `requested` is the normalized declaration baseline known to the host + * - `granted` is the currently granted subset that authorization checks should follow + * - plugins should prefer this snapshot over local assumptions when reconciling runtime state + */ +interface ModulePermissionsCurrentEvent { + identity: ModuleIdentity + requested: ModulePermissionDeclaration + granted: ModulePermissionGrant + revision: number } interface ModulePreparedEvent { @@ -848,10 +1002,24 @@ export const registryModulesHealthUnhealthy = defineEventa('registry:modules:health:healthy') export const error = defineEventa('error') +/** Permission-check failure event. See `ModulePermissionError`. */ +export const errorPermission = defineEventa('error:permission') export const moduleAnnounce = defineEventa('module:announce') export const moduleAnnounced = defineEventa('module:announced') export const moduleDeAnnounced = defineEventa('module:de-announced') + +/** Permission declaration lifecycle event. See `ModulePermissionsDeclareEvent`. */ +export const modulePermissionsDeclare = defineEventa('module:permissions:declare') +/** Permission request lifecycle event. See `ModulePermissionsRequestEvent`. */ +export const modulePermissionsRequest = defineEventa('module:permissions:request') +/** Permission grant lifecycle event. See `ModulePermissionsGrantedEvent`. */ +export const modulePermissionsGranted = defineEventa('module:permissions:granted') +/** Permission denial lifecycle event. See `ModulePermissionsDeniedEvent`. */ +export const modulePermissionsDenied = defineEventa('module:permissions:denied') +/** Current permission snapshot event. See `ModulePermissionsCurrentEvent`. */ +export const modulePermissionsCurrent = defineEventa('module:permissions:current') + export const modulePrepared = defineEventa('module:prepared') export const moduleConfigurationNeeded = defineEventa('module:configuration:needed') export const moduleStatus = defineEventa('module:status') @@ -906,6 +1074,7 @@ export const contextUpdate = defineEventa('context:update') // https://www.reddit.com/r/typescript/comments/1064ibt/a_little_hack_for_creating_extensible/ export interface ProtocolEvents { 'error': ErrorEvent + 'error:permission': ErrorPermissionEvent 'module:authenticate': ModuleAuthenticateEvent 'module:authenticated': ModuleAuthenticatedEvent @@ -940,6 +1109,11 @@ export interface ProtocolEvents { * module:announced or module:de-announced, or registry:modules:sync and registry:modules:health:* events for more reliable discovery and tracking. */ 'module:announce': ModuleAnnounceEvent + 'module:permissions:declare': ModulePermissionsDeclareEvent + 'module:permissions:request': ModulePermissionsRequestEvent + 'module:permissions:granted': ModulePermissionsGrantedEvent + 'module:permissions:denied': ModulePermissionsDeniedEvent + 'module:permissions:current': ModulePermissionsCurrentEvent /** * Broadcast to all peers when a module successfully announces. */ diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 5bd5cc6d8..0b657f526 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -43,6 +43,7 @@ "@moeru/eventa": "catalog:", "@proj-airi/plugin-protocol": "workspace:*", "@proj-airi/server-shared": "workspace:*", + "nanoid": "catalog:", "valibot": "^1.2.0", "xstate": "^5.28.0" } diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index ed1c3daa5..988645900 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -1,25 +1,58 @@ +import type { ModulePermissionDeclaration } from './shared/types' + import { join } from 'node:path' import { createContext, defineEventa, defineInvoke, defineInvokeHandler } from '@moeru/eventa' -import { moduleCompatibilityResult, moduleStatus, registryModulesSync } from '@proj-airi/plugin-protocol/types' +import { + moduleCompatibilityResult, + modulePermissionsCurrent, + modulePermissionsDeclare, + modulePermissionsDenied, + modulePermissionsGranted, + modulePermissionsRequest, + moduleStatus, + registryModulesSync, +} from '@proj-airi/plugin-protocol/types' import { describe, expect, it, vi } from 'vitest' import { FileSystemLoader, PluginHost } from '.' import { createApis } from '../plugin/apis/client' import { protocolCapabilityWait, protocolProviders } from '../plugin/apis/protocol' +function assertNever(value: never): never { + throw new Error(`Unsupported capability state: ${value}`) +} + function reportPluginCapability( host: PluginHost, payload: { key: string, state: 'announced' | 'ready', metadata?: Record }, ) { - if (payload.state === 'announced') { - return host.announceCapability(payload.key, payload.metadata) - } + switch (payload.state) { + case 'announced': + return host.announceCapability(payload.key, payload.metadata) - return host.markCapabilityReady(payload.key, payload.metadata) + case 'ready': + return host.markCapabilityReady(payload.key, payload.metadata) + + default: + return assertNever(payload.state) + } } describe('for FileSystemPluginHost', () => { + const testPermissions: ModulePermissionDeclaration = { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] }, + ], + capabilities: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] }, + ], + } + it('should load test-normal-plugin from manifest', async () => { const host = new FileSystemLoader() @@ -27,6 +60,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testPermissions, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), }, @@ -48,6 +82,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testPermissions, entrypoints: { node: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), }, @@ -64,6 +99,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testPermissions, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'), }, @@ -76,6 +112,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1' as const, kind: 'manifest.plugin.airi.moeru.ai' as const, name: 'test-plugin', + permissions: testPermissions, } const runtimeEntryManifest = { @@ -123,6 +160,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testPermissions, entrypoints: { node: '/opt/plugins/entry.ts', }, @@ -139,6 +177,7 @@ describe('for FileSystemPluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testPermissions, entrypoints: {}, }, { runtime: 'node' })).toThrow('Plugin entrypoint is required for runtime `node`.') }) @@ -150,6 +189,18 @@ describe('for PluginHost', () => { apiVersion: 'v1' as const, kind: 'manifest.plugin.airi.moeru.ai' as const, name: 'test-plugin', + permissions: { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] }, + ], + capabilities: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] }, + ], + } satisfies ModulePermissionDeclaration, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), }, @@ -196,6 +247,7 @@ describe('for PluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin-no-connect', + permissions: testManifest.permissions, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-no-connect-plugin.ts'), }, @@ -223,6 +275,7 @@ describe('for PluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-plugin', + permissions: testManifest.permissions, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), }, @@ -416,6 +469,7 @@ describe('for PluginHost', () => { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: 'test-reload-relative-entrypoint', + permissions: testManifest.permissions, entrypoints: { electron: './test-normal-plugin.ts', }, @@ -581,6 +635,20 @@ describe('for PluginHost', () => { await expect(invokeTwo()).resolves.toEqual([{ name: 'provider:two' }]) }) + it('should expose provider resources through the generic resource resolver API', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + + host.setResourceResolver(providersCapability, () => [{ name: 'provider:generic' }]) + + const session = await host.load(testManifest, { cwd: '' }) + const invokeProviders = defineInvoke(session.channels.host, protocolProviders.listProviders) + + await expect(invokeProviders()).resolves.toEqual([{ name: 'provider:generic' }]) + }) + it('should include active modules in registry sync when initializing another session', async () => { const host = new PluginHost({ runtime: 'electron', @@ -616,4 +684,283 @@ describe('for PluginHost', () => { expect(moduleNames).toContain('test-plugin-session-one') expect(moduleNames).toContain('test-plugin-session-two') }) + + it('should support runtime permission requests before granting deferred scopes', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + host.setResourceValue(providersCapability, [{ name: 'provider:runtime' }]) + + const session = await host.load({ + ...testManifest, + permissions: {}, + }, { cwd: '' }) + + const invokeProviders = defineInvoke(session.channels.host, protocolProviders.listProviders) + await expect(invokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) + + const declareEvents: Array<{ body?: Record }> = [] + const currentEvents: Array<{ body?: Record }> = [] + const requestEvents: Array<{ body?: Record }> = [] + const grantedEvents: Array<{ body?: Record }> = [] + + session.channels.host.on(modulePermissionsDeclare, payload => declareEvents.push(payload as unknown as { body?: Record })) + session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record })) + session.channels.host.on(modulePermissionsRequest, payload => requestEvents.push(payload as unknown as { body?: Record })) + session.channels.host.on(modulePermissionsGranted, payload => grantedEvents.push(payload as unknown as { body?: Record })) + + const runtimeRequest = { + apis: [ + { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, + ], + resources: [ + { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, + ], + } satisfies ModulePermissionDeclaration + + host.requestPermissions(session.id, runtimeRequest, 'Enable provider lookup') + + expect(host.getSession(session.id)?.permissions.requested).toEqual({ + apis: [ + { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, + ], + resources: [ + { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, + ], + capabilities: [], + processors: [], + pipelines: [], + }) + expect(requestEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + requested: expect.objectContaining({ + apis: [ + expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), + ], + resources: [ + expect.objectContaining({ key: providersCapability, actions: ['read'] }), + ], + }), + reason: 'Enable provider lookup', + }), + }), + ])) + expect(declareEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + source: 'runtime', + }), + }), + ])) + expect(currentEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + requested: expect.objectContaining({ + apis: [ + expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), + ], + }), + granted: expect.objectContaining({ + apis: [], + resources: [], + }), + }), + }), + ])) + + await expect(invokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) + + host.grantPermissions(session.id, { + apis: [ + { key: providersCapability, actions: ['invoke'] }, + ], + resources: [ + { key: providersCapability, actions: ['read'] }, + ], + }) + + expect(host.getSession(session.id)?.permissions.granted).toEqual({ + apis: [ + { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, + ], + resources: [ + { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, + ], + capabilities: [], + processors: [], + pipelines: [], + }) + expect(grantedEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + granted: expect.objectContaining({ + apis: [ + expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), + ], + resources: [ + expect.objectContaining({ key: providersCapability, actions: ['read'] }), + ], + }), + }), + }), + ])) + + await expect(invokeProviders()).resolves.toEqual([{ name: 'provider:runtime' }]) + }) + + it('should only emit denied scopes that remain precisely representable after partial approval', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + permissionResolver: ({ requested }) => ({ + apis: [ + ...(requested.apis ?? []).filter(spec => spec.key.startsWith('proj-airi:plugin-sdk:')), + { key: 'plugin.api.users', actions: ['invoke'] }, + ], + resources: [ + ...(requested.resources ?? []).filter(spec => spec.key.startsWith('proj-airi:plugin-sdk:')), + { key: 'plugin.resource.settings', actions: ['read'] }, + ], + capabilities: requested.capabilities, + }), + }) + + const manifest = { + apiVersion: 'v1' as const, + kind: 'manifest.plugin.airi.moeru.ai' as const, + name: 'test-plugin-denied-partial', + permissions: { + apis: [ + ...(testManifest.permissions.apis ?? []), + { key: 'plugin.api.users', actions: ['invoke', 'emit'], reason: 'Use selected user API actions' }, + ], + resources: [ + ...(testManifest.permissions.resources ?? []), + { key: 'plugin.resource.*', actions: ['read'], reason: 'Read plugin resources' }, + ], + capabilities: testManifest.permissions.capabilities, + } satisfies ModulePermissionDeclaration, + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + }, + } + + const session = await host.load(manifest, { cwd: '' }) + const deniedEvents: Array<{ body?: Record }> = [] + const currentEvents: Array<{ body?: Record }> = [] + session.channels.host.on(modulePermissionsDenied, payload => deniedEvents.push(payload as unknown as { body?: Record })) + session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record })) + + await host.init(session.id) + + expect(session.permissions.granted).toEqual({ + apis: [ + ...(testManifest.permissions.apis ?? []), + { key: 'plugin.api.users', actions: ['invoke'], reason: 'Use selected user API actions' }, + ], + resources: [ + ...(testManifest.permissions.resources ?? []), + { key: 'plugin.resource.settings', actions: ['read'], reason: 'Read plugin resources' }, + ], + capabilities: testManifest.permissions.capabilities ?? [], + processors: [], + pipelines: [], + }) + + expect(deniedEvents).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ + denied: { + apis: [ + { key: 'plugin.api.users', actions: ['emit'], reason: 'Use selected user API actions' }, + ], + }, + }), + }), + ]) + expect(deniedEvents[0]?.body?.denied).not.toHaveProperty('resources') + expect(currentEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + granted: { + apis: [ + ...(testManifest.permissions.apis ?? []), + { key: 'plugin.api.users', actions: ['invoke'], reason: 'Use selected user API actions' }, + ], + resources: [ + ...(testManifest.permissions.resources ?? []), + { key: 'plugin.resource.settings', actions: ['read'], reason: 'Read plugin resources' }, + ], + capabilities: testManifest.permissions.capabilities ?? [], + processors: [], + pipelines: [], + }, + }), + }), + ])) + }) + + it('should isolate runtime permission grants between concurrent same-name sessions', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + host.setResourceValue(providersCapability, [{ name: 'provider:runtime' }]) + + const manifest = { + ...testManifest, + permissions: {}, + } + + const firstSession = await host.load(manifest, { cwd: '' }) + const secondSession = await host.load(manifest, { cwd: '' }) + + const firstInvokeProviders = defineInvoke(firstSession.channels.host, protocolProviders.listProviders) + const secondInvokeProviders = defineInvoke(secondSession.channels.host, protocolProviders.listProviders) + + const runtimeRequest = { + apis: [ + { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, + ], + resources: [ + { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, + ], + } satisfies ModulePermissionDeclaration + + host.requestPermissions(firstSession.id, runtimeRequest) + host.requestPermissions(secondSession.id, runtimeRequest) + + host.grantPermissions(firstSession.id, { + apis: [ + { key: providersCapability, actions: ['invoke'] }, + ], + resources: [ + { key: providersCapability, actions: ['read'] }, + ], + }) + + await expect(firstInvokeProviders()).resolves.toEqual([{ name: 'provider:runtime' }]) + await expect(secondInvokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) + + expect(host.getSession(firstSession.id)?.permissions.granted).toEqual({ + apis: [ + { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, + ], + resources: [ + { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, + ], + capabilities: [], + processors: [], + pipelines: [], + }) + expect(host.getSession(secondSession.id)?.permissions.granted).toEqual({ + apis: [], + resources: [], + capabilities: [], + processors: [], + pipelines: [], + }) + }) }) diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 87e3a42bf..f647f28dc 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -1,23 +1,27 @@ -import type { - ProtocolEvents, - ModuleConfigEnvelope as ProtocolModuleConfigEnvelope, - ModuleIdentity as ProtocolModuleIdentity, - ModulePhase as ProtocolModulePhase, - PluginIdentity as ProtocolPluginIdentity, -} from '@proj-airi/plugin-protocol/types' import type { ActorRefFrom } from 'xstate' -import type { definePlugin } from '../plugin' import type { createApis } from '../plugin/apis/client' -import type { CapabilityDescriptor } from '../plugin/apis/protocol' import type { Plugin } from '../plugin/shared' +import type { + ManifestV1, + ModuleCompatibilityRequest, + ModuleConfigEnvelope, + ModuleIdentity, + ModulePermissionDeclaration, + ModulePermissionGrant, + PluginHostOptions, + PluginLoadOptions, + PluginRuntime, + PluginSessionPhase, + PluginStartOptions, +} from './shared/types' import type { PluginTransport } from './transports' -import { isAbsolute, join } from 'node:path' import { cwd } from 'node:process' import { defineInvokeHandler } from '@moeru/eventa' import { + errorPermission, moduleAnnounce, moduleAuthenticate, moduleAuthenticated, @@ -25,22 +29,36 @@ import { moduleCompatibilityResult, moduleConfigurationConfigured, moduleConfigurationNeeded, + modulePermissionsCurrent, + modulePermissionsDeclare, + modulePermissionsDenied, + modulePermissionsGranted, + modulePermissionsRequest, modulePrepared, moduleStatus, registryModulesSync, } from '@proj-airi/plugin-protocol/types' -import { - literal, - object, - optional, - string, -} from 'valibot' import { createActor, createMachine } from 'xstate' import { createApis as createBoundApis } from '../plugin/apis/client' -import { protocolCapabilitySnapshot, protocolCapabilityWait } from '../plugin/apis/protocol' -import { protocolListProvidersEventName, protocolProviders } from '../plugin/apis/protocol/resources/providers' +import { + protocolCapabilitySnapshot, + protocolCapabilitySnapshotEventName, + protocolCapabilityWait, + protocolCapabilityWaitEventName, +} from '../plugin/apis/protocol' +import { + protocolListProvidersEventName, + protocolProviders, +} from '../plugin/apis/protocol/resources/providers' import { createPluginContext } from './runtimes/node' +import { FileSystemLoader } from './runtimes/node/loaders' +import { + DependencyService, + PermissionService, + PluginSessionService, + ResourceService, +} from './runtimes/shared' /** * Plugin Host lifecycle overview (transport-aware): @@ -304,51 +322,6 @@ function markFailedTransition(session: PluginHostSession) { } } -function isPluginDefinition(value: unknown): value is ReturnType { - return typeof value === 'object' - && value !== null - && 'setup' in value - && typeof (value as { setup?: unknown }).setup === 'function' -} - -async function coercePluginFromModule(moduleValue: unknown): Promise { - if (isPluginDefinition(moduleValue)) { - return await moduleValue.setup() - } - - if (typeof moduleValue === 'object' && moduleValue !== null) { - if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) { - return await (moduleValue as { default: ReturnType }).default.setup() - } - - if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') { - const defaultPlugin = (moduleValue as { default: Plugin }).default - if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') { - return defaultPlugin - } - } - - const plugin = moduleValue as Plugin - if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') { - return plugin - } - } - - throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.') -} - -function createModuleIdentity(name: string, index: number): ModuleIdentity { - const sanitizedName = name.trim() || 'plugin' - - return { - id: `${sanitizedName}-${index}`, - kind: 'plugin', - plugin: { - id: sanitizedName, - }, - } -} - // TODO: Maybe support more complex version formats. function normalizeVersionList(versions: string[]) { return [...new Set(versions.map(version => version.trim()).filter(Boolean))] @@ -402,74 +375,124 @@ function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersion } } -export type PluginRuntime = 'electron' | 'node' | 'web' +function filterDeniedPermissions(requested: ModulePermissionDeclaration, granted: ModulePermissionGrant): ModulePermissionDeclaration { + const denied: ModulePermissionDeclaration = {} + const deniedApis = filterDeniedPermissionScopes(requested.apis, granted.apis) + const deniedResources = filterDeniedPermissionScopes(requested.resources, granted.resources) + const deniedCapabilities = filterDeniedPermissionScopes(requested.capabilities, granted.capabilities) + const deniedProcessors = filterDeniedPermissionScopes(requested.processors, granted.processors) + const deniedPipelines = filterDeniedPermissionScopes(requested.pipelines, granted.pipelines) -export type ModulePhase = ProtocolModulePhase - -export type PluginSessionPhase - = | 'loading' - | 'loaded' - | 'authenticating' - | 'authenticated' - | 'waiting-deps' - | ModulePhase - | 'stopped' - -export type PluginIdentity = ProtocolPluginIdentity - -export type ModuleIdentity = ProtocolModuleIdentity - -export type ModuleConfigEnvelope> = ProtocolModuleConfigEnvelope - -export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request'] - -export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result'] - -export interface ManifestV1 { - apiVersion: 'v1' - kind: 'manifest.plugin.airi.moeru.ai' - name: string - entrypoints: { - default?: string - electron?: string - node?: string - web?: string + if (deniedApis.length > 0) { + denied.apis = deniedApis } + + if (deniedResources.length > 0) { + denied.resources = deniedResources + } + + if (deniedCapabilities.length > 0) { + denied.capabilities = deniedCapabilities + } + + if (deniedProcessors.length > 0) { + denied.processors = deniedProcessors + } + + if (deniedPipelines.length > 0) { + denied.pipelines = deniedPipelines + } + + return denied } -export const manifestV1Schema = object({ - apiVersion: literal('v1'), - kind: literal('manifest.plugin.airi.moeru.ai'), - name: string(), - entrypoints: object({ - default: optional(string()), - electron: optional(string()), - node: optional(string()), - web: optional(string()), - }), -}) +function matchPermissionKey(pattern: string, target: string) { + if (pattern === '*') { + return true + } -export interface PluginLoadOptions { - cwd?: string - runtime?: PluginRuntime + if (pattern.endsWith('*')) { + return target.startsWith(pattern.slice(0, -1)) + } + + return pattern === target } -export interface PluginHostOptions { - runtime?: PluginRuntime - transport?: PluginTransport - protocolVersion?: string - apiVersion?: string - supportedProtocolVersions?: string[] - supportedApiVersions?: string[] +function getPermissionIntersectionKey(left: string, right: string) { + if (matchPermissionKey(left, right)) { + return right + } + + if (matchPermissionKey(right, left)) { + return left + } + + return undefined } -export interface PluginStartOptions { - cwd?: string - runtime?: PluginRuntime - requireConfiguration?: boolean - compatibility?: Omit - requiredCapabilities?: string[] - capabilityWaitTimeoutMs?: number +function filterDeniedPermissionScopes< + T extends { + key: string + actions: string[] + }, +>(requested: T[] | undefined, granted: T[] | undefined): T[] { + if (!requested?.length) { + return [] + } + + return requested.flatMap((requestedSpec) => { + const grantedActions = new Set() + let hasUnRepresentableOverlap = false + + for (const grantedSpec of granted ?? []) { + const intersectionKey = getPermissionIntersectionKey(requestedSpec.key, grantedSpec.key) + if (!intersectionKey) { + continue + } + + if (intersectionKey !== requestedSpec.key) { + // A narrower grant overlaps only part of the requested scope, such as: + // - requested `plugin.resource.*` + // - granted `plugin.resource.settings` + // + // The current declaration shape cannot express "everything except the granted subset", + // so reporting the whole requested scope as denied would contradict the granted/current + // snapshots. In that case we omit the denied entry rather than over-reporting it. + hasUnRepresentableOverlap = true + continue + } + + for (const action of grantedSpec.actions) { + if (requestedSpec.actions.includes(action)) { + grantedActions.add(action) + } + } + } + + const deniedActions = requestedSpec.actions.filter(action => !grantedActions.has(action)) + if (deniedActions.length === 0 || hasUnRepresentableOverlap) { + return [] + } + + return [{ + ...requestedSpec, + actions: deniedActions, + }] + }) +} + +class PermissionDeniedError extends Error { + readonly details: { + area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines' + action: string + key: string + } + + constructor(details: PermissionDeniedError['details']) { + super(`Permission denied: ${details.area}.${details.action} "${details.key}"`) + this.name = 'PermissionDeniedError' + this.details = details + } } export interface PluginHostSession { @@ -487,6 +510,11 @@ export interface PluginHostSession { host: ReturnType } apis: ReturnType + permissions: { + requested: ModulePermissionDeclaration + granted: ModulePermissionGrant + revision: number + } } /** @@ -508,17 +536,18 @@ export interface PluginHostSession { */ export class PluginHost { private readonly loader: FileSystemLoader - private readonly sessions = new Map() + private readonly sessionService = new PluginSessionService() private readonly runtime: PluginRuntime private readonly transport: PluginTransport private readonly protocolVersion: string private readonly apiVersion: string private readonly supportedProtocolVersions: string[] private readonly supportedApiVersions: string[] - private readonly capabilities = new Map() - private readonly capabilityWaiters = new Map void>>() - private providersListResolver: () => Promise> | Array<{ name: string }> = () => [] - private sessionCounter = 0 + private readonly dependencies = new DependencyService() + private readonly permissions = new PermissionService() + private readonly permissionResolver?: PluginHostOptions['permissionResolver'] + private readonly persistedPermissionGrants = new Map() + private readonly resources = new ResourceService() constructor(options: PluginHostOptions = {}) { this.loader = new FileSystemLoader() @@ -528,15 +557,55 @@ export class PluginHost { this.apiVersion = options.apiVersion ?? 'v1' this.supportedProtocolVersions = resolveSupportedVersions(this.protocolVersion, options.supportedProtocolVersions) this.supportedApiVersions = resolveSupportedVersions(this.apiVersion, options.supportedApiVersions) + this.permissionResolver = options.permissionResolver + this.resources.setValue(protocolListProvidersEventName, [] as Array<{ name: string }>) this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' }) } + private getPermissionScopeKey(session: PluginHostSession) { + return session.id + } + + private assertPermission( + session: PluginHostSession, + input: { + area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines' + action: string + key: string + reason?: string + }, + ) { + const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key) + if (allowed) { + return + } + + const error = new PermissionDeniedError({ + area: input.area, + action: input.action, + key: input.key, + }) + + session.channels.host.emit(errorPermission, { + identity: session.identity, + error: { + area: input.area, + action: input.action, + key: input.key, + reason: input.reason ?? 'Permission not granted for requested operation.', + recoverable: true, + }, + }) + + throw error + } + listSessions() { - return [...this.sessions.values()] + return this.sessionService.list() } getSession(sessionId: string) { - return this.sessions.get(sessionId) + return this.sessionService.get(sessionId) } async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise { @@ -552,27 +621,20 @@ export class PluginHost { throw new Error(`Only in-memory transport is currently supported by PluginHost alpha. Got: ${transport.kind}`) } - // Build deterministic per-session identity. - // `sessionCounter` gives stable ordering for registry sync and debugging. - const sessionIndex = this.sessionCounter - this.sessionCounter += 1 - - const id = `plugin-session-${sessionIndex}` - const identity = createModuleIdentity(manifest.name, sessionIndex) + // Build per-session identity. + const sessionIdentity = this.sessionService.nextSessionIdentity(manifest.name) + const sessionIndex = sessionIdentity.index + const id = sessionIdentity.sessionId + const identity = sessionIdentity.moduleIdentity // Step 1 (connect/control-plane prep): create an isolated Eventa context per plugin. // All invokes/events for this plugin go through this context to prevent cross-talk. const hostChannel = createPluginContext(transport) const lifecycle = createActor(pluginLifecycleMachine) lifecycle.start() - defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => { - return await this.waitForCapability(payload.key, payload?.timeoutMs) - }) - defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => { - return this.listCapabilities() - }) - defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => { - return await this.providersListResolver() + + const permissionSnapshot = this.permissions.initialize(id, manifest.permissions, { + persisted: this.persistedPermissionGrants.get(identity.plugin.id), }) const session: PluginHostSession = { @@ -590,10 +652,55 @@ export class PluginHost { host: hostChannel, }, apis: createBoundApis(hostChannel), + permissions: { + requested: permissionSnapshot.requested, + granted: permissionSnapshot.granted, + revision: permissionSnapshot.revision, + }, } + defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: protocolCapabilityWaitEventName, + }) + this.assertPermission(session, { + area: 'capabilities', + action: 'wait', + key: payload.key, + }) + return await this.waitForCapability(payload.key, payload?.timeoutMs) + }) + defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: protocolCapabilitySnapshotEventName, + }) + this.assertPermission(session, { + area: 'capabilities', + action: 'snapshot', + key: '*', + }) + return this.listCapabilities() + }) + defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: protocolListProvidersEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'read', + key: protocolListProvidersEventName, + }) + return await this.resources.get>(protocolListProvidersEventName, []) ?? [] + }) + // Register session before loading so failure paths still have observable state. - this.sessions.set(id, session) + this.sessionService.register(session) try { // Load plugin module from manifest-selected runtime entrypoint. @@ -624,7 +731,7 @@ export class PluginHost { async init(sessionId: string, options: PluginStartOptions = {}): Promise { // `init` starts at procedure step 2 (authenticate) and drives lifecycle to ready. - const session = this.sessions.get(sessionId) + const session = this.sessionService.get(sessionId) if (!session) { throw new Error(`Unable to initialize plugin session: ${sessionId}`) } @@ -700,12 +807,58 @@ export class PluginHost { })), }) + session.channels.host.emit(modulePermissionsDeclare, { + identity: session.identity, + requested: session.permissions.requested, + source: 'manifest', + }) + + const resolvedGrant = await this.permissionResolver?.({ + identity: session.identity, + manifest: session.manifest, + requested: session.permissions.requested, + persisted: this.persistedPermissionGrants.get(session.identity.plugin.id), + }) ?? session.permissions.requested + + const grantedSnapshot = this.permissions.initialize(this.getPermissionScopeKey(session), session.permissions.requested, { + grant: resolvedGrant, + persisted: this.persistedPermissionGrants.get(session.identity.plugin.id), + }) + session.permissions = { + requested: grantedSnapshot.requested, + granted: grantedSnapshot.granted, + revision: grantedSnapshot.revision, + } + this.persistedPermissionGrants.set(session.identity.plugin.id, grantedSnapshot.granted) + + const deniedPermissions = filterDeniedPermissions(grantedSnapshot.requested, grantedSnapshot.granted) + session.channels.host.emit(modulePermissionsGranted, { + identity: session.identity, + granted: grantedSnapshot.granted, + revision: grantedSnapshot.revision, + }) + if (Object.values(deniedPermissions).some(value => Array.isArray(value) && value.length > 0)) { + session.channels.host.emit(modulePermissionsDenied, { + identity: session.identity, + denied: deniedPermissions, + reason: 'One or more requested permissions were not granted by host policy.', + revision: grantedSnapshot.revision, + }) + } + session.channels.host.emit(modulePermissionsCurrent, { + identity: session.identity, + requested: grantedSnapshot.requested, + granted: grantedSnapshot.granted, + revision: grantedSnapshot.revision, + }) + // Step 5: module announcement to the shared control plane. assertTransition(session, 'announced') session.channels.host.emit(moduleAnnounce, { name: session.manifest.name, identity: session.identity, possibleEvents: [], + permissions: session.permissions.requested, }) session.channels.host.emit(moduleStatus, { identity: session.identity, @@ -836,7 +989,7 @@ export class PluginHost { async applyConfiguration(sessionId: string, config: ModuleConfigEnvelope) { // Configuration is allowed only after prepare, during configuration-needed, or while re-configuring. - const session = this.sessions.get(sessionId) + const session = this.sessionService.get(sessionId) if (!session) { throw new Error(`Unable to configure plugin session: ${sessionId}`) } @@ -864,117 +1017,117 @@ export class PluginHost { return session } - setProvidersListResolver(resolver: () => Promise> | Array<{ name: string }>) { - this.providersListResolver = resolver - this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' }) + requestPermissions(sessionId: string, requested: ModulePermissionDeclaration, reason?: string) { + const session = this.sessionService.get(sessionId) + if (!session) { + throw new Error(`Unable to request permissions for plugin session: ${sessionId}`) + } + + const snapshot = this.permissions.declare(this.getPermissionScopeKey(session), requested) + session.permissions = { + requested: snapshot.requested, + granted: snapshot.granted, + revision: snapshot.revision, + } + + session.channels.host.emit(modulePermissionsDeclare, { + identity: session.identity, + requested: snapshot.requested, + source: 'runtime', + }) + session.channels.host.emit(modulePermissionsCurrent, { + identity: session.identity, + requested: snapshot.requested, + granted: snapshot.granted, + revision: snapshot.revision, + }) + session.channels.host.emit(modulePermissionsRequest, { + identity: session.identity, + requested: snapshot.requested, + reason, + }) + } + + grantPermissions( + sessionId: string, + grant: ModulePermissionGrant, + ): { + requested: ModulePermissionDeclaration + granted: ModulePermissionGrant + revision: number + } { + const session = this.sessionService.get(sessionId) + if (!session) { + throw new Error(`Unable to grant permissions for plugin session: ${sessionId}`) + } + + const snapshot = this.permissions.grant(this.getPermissionScopeKey(session), grant) + session.permissions = { + requested: snapshot.requested, + granted: snapshot.granted, + revision: snapshot.revision, + } + this.persistedPermissionGrants.set(session.identity.plugin.id, snapshot.granted) + + session.channels.host.emit(modulePermissionsGranted, { + identity: session.identity, + granted: snapshot.granted, + revision: snapshot.revision, + }) + session.channels.host.emit(modulePermissionsCurrent, { + identity: session.identity, + requested: snapshot.requested, + granted: snapshot.granted, + revision: snapshot.revision, + }) + + return snapshot + } + + setResourceResolver(key: string, resolver: () => Promise | T) { + this.resources.setResolver(key, resolver) + } + + setResourceValue(key: string, value: T) { + this.resources.setValue(key, value) } announceCapability(key: string, metadata?: Record) { - const current = this.capabilities.get(key) - const descriptor: CapabilityDescriptor = { - key, - state: 'announced', - metadata: metadata ?? current?.metadata, - updatedAt: Date.now(), - } - - this.capabilities.set(key, descriptor) - return descriptor + return this.dependencies.announce(key, metadata) } markCapabilityReady(key: string, metadata?: Record) { - const current = this.capabilities.get(key) - const descriptor: CapabilityDescriptor = { - key, - state: 'ready', - metadata: metadata ?? current?.metadata, - updatedAt: Date.now(), - } - - this.capabilities.set(key, descriptor) - const waiters = this.capabilityWaiters.get(key) - if (waiters) { - for (const resolve of waiters) { - resolve(descriptor) - } - this.capabilityWaiters.delete(key) - } - - return descriptor + return this.dependencies.markReady(key, metadata) } markCapabilityDegraded(key: string, metadata?: Record) { - const current = this.capabilities.get(key) - const descriptor: CapabilityDescriptor = { - key, - state: 'degraded', - metadata: metadata ?? current?.metadata, - updatedAt: Date.now(), - } - - this.capabilities.set(key, descriptor) - return descriptor + return this.dependencies.markDegraded(key, metadata) } withdrawCapability(key: string, metadata?: Record) { - const current = this.capabilities.get(key) - const descriptor: CapabilityDescriptor = { - key, - state: 'withdrawn', - metadata: metadata ?? current?.metadata, - updatedAt: Date.now(), - } - - this.capabilities.set(key, descriptor) - return descriptor + return this.dependencies.withdraw(key, metadata) } listCapabilities() { - return [...this.capabilities.values()] + return this.dependencies.list() } isCapabilityReady(key: string) { - return this.capabilities.get(key)?.state === 'ready' + return this.dependencies.isReady(key) } async waitForCapabilities(keys: string[], timeoutMs: number = 15000) { - await Promise.all(keys.map(async key => await this.waitForCapability(key, timeoutMs))) + await this.dependencies.waitForMany(keys, timeoutMs) } async waitForCapability(key: string, timeoutMs: number = 15000) { - const existing = this.capabilities.get(key) - if (existing?.state === 'ready') { - return existing - } - - return await new Promise((resolve, reject) => { - let timeout: ReturnType | undefined - const onReady = (descriptor: CapabilityDescriptor) => { - if (timeout) { - clearTimeout(timeout) - } - resolve(descriptor) - } - - const waiters = this.capabilityWaiters.get(key) ?? new Set() - waiters.add(onReady) - this.capabilityWaiters.set(key, waiters) - - timeout = setTimeout(() => { - const currentWaiters = this.capabilityWaiters.get(key) - currentWaiters?.delete(onReady) - if (currentWaiters && currentWaiters.size === 0) { - this.capabilityWaiters.delete(key) - } - reject(new Error(`Capability \`${key}\` is not ready after ${timeoutMs}ms.`)) - }, timeoutMs) - }) + return await this.dependencies.waitFor(key, timeoutMs) } markConfigurationNeeded(sessionId: string, reason?: string) { // Explicit rollback/forward hook into "configuration-needed" phase. // Mirrors procedure step 17 where module may request reconfiguration. - const session = this.sessions.get(sessionId) + const session = this.sessionService.get(sessionId) if (!session) { throw new Error(`Unable to update plugin session: ${sessionId}`) } @@ -1000,7 +1153,7 @@ export class PluginHost { stop(sessionId: string) { // Stop removes session from active registry. Lifecycle first transitions to `stopped`. - const session = this.sessions.get(sessionId) + const session = this.sessionService.get(sessionId) if (!session) { return undefined } @@ -1017,14 +1170,14 @@ export class PluginHost { } session.lifecycle.stop() - this.sessions.delete(session.id) + this.sessionService.remove(session.id) return session } async reload(sessionId: string, options: PluginStartOptions = {}) { // Reload preserves manifest/runtime intent, then performs stop + fresh start. // This intentionally creates a new session identity for deterministic re-bootstrap. - const previous = this.sessions.get(sessionId) + const previous = this.sessionService.get(sessionId) if (!previous) { throw new Error(`Unable to reload missing plugin session: ${sessionId}`) } @@ -1038,86 +1191,3 @@ export class PluginHost { }) } } - -export class FileSystemLoader { - /** - * Filesystem-backed plugin module loader. - * - * Responsibilities: - * - Resolve runtime-specific entrypoints from `ManifestV1`. - * - Import plugin modules from local disk. - * - Normalize module exports into either: - * - lazy plugin definition (`definePlugin(...)`) via `loadLazyPluginFor`, or - * - executable plugin hooks (`Plugin`) via `loadPluginFor`. - */ - constructor() { - - } - - /** - * Resolve a manifest entrypoint for the requested runtime. - * - * Resolution order: - * 1) `entrypoints.` - * 2) `entrypoints.default` - * 3) `entrypoints.electron` (legacy fallback for current local plugin manifests) - * - * Throws an actionable error when no entrypoint can be selected. - */ - resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) { - const runtime = options?.runtime ?? 'electron' - const root = options?.cwd ?? cwd() - const entrypoint - = manifest.entrypoints[runtime] - ?? manifest.entrypoints.default - ?? manifest.entrypoints.electron - - if (!entrypoint) { - throw new Error('' - + `Plugin entrypoint is required for runtime \`${runtime}\`. ` - + 'Define one of `entrypoints.`, `entrypoints.default`, ' - + 'or `entrypoints.electron` in the plugin manifest.', - ) - } - - return isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint) - } - - /** - * Load a lazy plugin definition (`definePlugin(...)`) without executing setup. - * - * Use this when host logic wants to inspect plugin metadata/setup contract first - * and control when `setup()` is called. - */ - async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { - const entrypoint = this.resolveEntrypointFor(manifest, options) - const pluginModule = await import(entrypoint) - - if (isPluginDefinition(pluginModule)) { - return pluginModule - } - - if (typeof pluginModule === 'object' && pluginModule !== null) { - const defaultExport = (pluginModule as { default?: unknown }).default - if (isPluginDefinition(defaultExport)) { - return defaultExport - } - } - - throw new Error('Plugin lazy loader expects a definePlugin(...) export.') - } - - /** - * Load and normalize a plugin entrypoint into executable `Plugin` hooks. - * - * Accepts: - * - a direct `Plugin` export - * - a default `Plugin` export - * - `definePlugin(...)` (calls `setup()` and returns the resulting `Plugin`) - */ - async loadPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { - const entrypoint = this.resolveEntrypointFor(manifest, options) - const pluginModule = await import(entrypoint) - return coercePluginFromModule(pluginModule) - } -} diff --git a/packages/plugin-sdk/src/plugin-host/index.ts b/packages/plugin-sdk/src/plugin-host/index.ts index 08a08e28b..06cea200d 100644 --- a/packages/plugin-sdk/src/plugin-host/index.ts +++ b/packages/plugin-sdk/src/plugin-host/index.ts @@ -1,3 +1,6 @@ export * from './core' export { createPluginContext } from './runtimes/node' +export * from './runtimes/node/loaders' +export * from './runtimes/shared' +export * from './shared' export * from './transports' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts index 90dd8d29c..f75c4551a 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts @@ -5,7 +5,9 @@ import type { PluginTransport } from '../../transports' import { createContext } from '@moeru/eventa' export * from '../../core' +export * from '../../shared' export * from '../../transports' +export * from './loaders' export function createPluginContext(transport: PluginTransport): EventContext { switch (transport.kind) { diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts new file mode 100644 index 000000000..7e9af7f81 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts @@ -0,0 +1,92 @@ +import type { definePlugin } from '../../../../plugin' +import type { Plugin } from '../../../../plugin/shared' +import type { ManifestV1, PluginLoadOptions } from '../../../shared/types' + +import { isAbsolute, join } from 'node:path' +import { cwd } from 'node:process' + +function isPluginDefinition(value: unknown): value is ReturnType { + return typeof value === 'object' + && value !== null + && 'setup' in value + && typeof (value as { setup?: unknown }).setup === 'function' +} + +async function coercePluginFromModule(moduleValue: unknown): Promise { + if (isPluginDefinition(moduleValue)) { + return await moduleValue.setup() + } + + if (typeof moduleValue === 'object' && moduleValue !== null) { + if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) { + return await (moduleValue as { default: ReturnType }).default.setup() + } + + if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') { + const defaultPlugin = (moduleValue as { default: Plugin }).default + if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') { + return defaultPlugin + } + } + + const plugin = moduleValue as Plugin + if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') { + return plugin + } + } + + throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.') +} + +export class FileSystemLoader { + /** + * Resolve a manifest entrypoint for the requested runtime. + * + * Resolution order: + * 1) `entrypoints.` + * 2) `entrypoints.default` + * 3) `entrypoints.electron` (legacy fallback for current local plugin manifests) + */ + resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) { + const runtime = options?.runtime ?? 'electron' + const root = options?.cwd ?? cwd() + const entrypoint + = manifest.entrypoints[runtime] + ?? manifest.entrypoints.default + ?? manifest.entrypoints.electron + + if (!entrypoint) { + throw new Error('' + + `Plugin entrypoint is required for runtime \`${runtime}\`. ` + + 'Define one of `entrypoints.`, `entrypoints.default`, ' + + 'or `entrypoints.electron` in the plugin manifest.', + ) + } + + return isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint) + } + + async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { + const entrypoint = this.resolveEntrypointFor(manifest, options) + const pluginModule = await import(entrypoint) + + if (isPluginDefinition(pluginModule)) { + return pluginModule + } + + if (typeof pluginModule === 'object' && pluginModule !== null) { + const defaultExport = (pluginModule as { default?: unknown }).default + if (isPluginDefinition(defaultExport)) { + return defaultExport + } + } + + throw new Error('Plugin lazy loader expects a definePlugin(...) export.') + } + + async loadPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { + const entrypoint = this.resolveEntrypointFor(manifest, options) + const pluginModule = await import(entrypoint) + return coercePluginFromModule(pluginModule) + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/index.ts new file mode 100644 index 000000000..668241973 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/index.ts @@ -0,0 +1 @@ +export * from './fs' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/index.ts new file mode 100644 index 000000000..ef147a362 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/index.ts @@ -0,0 +1 @@ +export * from './services' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.test.ts new file mode 100644 index 000000000..4d2cc4d6d --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DependencyService } from './dependencies' + +describe('dependencyService', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('should track lifecycle transitions and preserve existing metadata when omitted', () => { + const service = new DependencyService() + + { + const announced = service.announce('cap:dynamic', { source: 'announce' }) + expect(announced).toMatchObject({ + key: 'cap:dynamic', + state: 'announced', + metadata: { source: 'announce' }, + }) + expect(service.isReady('cap:dynamic')).toBe(false) + expect(service.list()).toEqual([ + expect.objectContaining({ + key: 'cap:dynamic', + state: 'announced', + }), + ]) + } + + { + const degraded = service.markDegraded('cap:dynamic') + expect(degraded).toMatchObject({ + key: 'cap:dynamic', + state: 'degraded', + metadata: { source: 'announce' }, + }) + expect(service.isReady('cap:dynamic')).toBe(false) + expect(service.list()).toEqual([ + expect.objectContaining({ + key: 'cap:dynamic', + state: 'degraded', + }), + ]) + } + + { + const withdrawn = service.withdraw('cap:dynamic', { reason: 'disabled' }) + expect(withdrawn).toMatchObject({ + key: 'cap:dynamic', + state: 'withdrawn', + metadata: { reason: 'disabled' }, + }) + expect(service.isReady('cap:dynamic')).toBe(false) + expect(service.list()).toEqual([ + expect.objectContaining({ + key: 'cap:dynamic', + state: 'withdrawn', + metadata: { reason: 'disabled' }, + }), + ]) + } + + const ready = service.markReady('cap:dynamic') + expect(ready).toMatchObject({ + key: 'cap:dynamic', + state: 'ready', + metadata: { reason: 'disabled' }, + }) + expect(service.isReady('cap:dynamic')).toBe(true) + expect(service.list()).toEqual([ + expect.objectContaining({ + key: 'cap:dynamic', + state: 'ready', + metadata: { reason: 'disabled' }, + }), + ]) + }) + + it('should resolve immediately when waiting for an already ready capability', async () => { + const service = new DependencyService() + const descriptor = service.markReady('cap:ready', { source: 'bootstrap' }) + + await expect(service.waitFor('cap:ready')).resolves.toEqual(descriptor) + }) + + it('should resolve waits only when the capability reaches ready state', async () => { + vi.useFakeTimers() + + const service = new DependencyService() + service.markDegraded('cap:unstable', { reason: 'booting' }) + + const waiting = service.waitFor('cap:unstable', 2_000) + + service.withdraw('cap:unstable', { reason: 'restarting' }) + const ready = service.markReady('cap:unstable', { source: 'recovered' }) + + await vi.runAllTimersAsync() + await expect(waiting).resolves.toEqual(ready) + }) + + it('should wait for multiple capabilities before resolving', async () => { + const service = new DependencyService() + const waiting = service.waitForMany(['cap:a', 'cap:b'], 2_000) + let settled = false + void waiting.then(() => { + settled = true + }) + + service.markReady('cap:a', { source: 'a' }) + await Promise.resolve() + expect(settled).toBe(false) + + service.markReady('cap:b', { source: 'b' }) + + await expect(waiting).resolves.toBeUndefined() + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts new file mode 100644 index 000000000..d5a2c3921 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts @@ -0,0 +1,108 @@ +import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol' + +export class DependencyService { + private readonly capabilities = new Map() + private readonly capabilityWaiters = new Map void>>() + + announce(key: string, metadata?: Record) { + const current = this.capabilities.get(key) + const descriptor: CapabilityDescriptor = { + key, + state: 'announced', + metadata: metadata ?? current?.metadata, + updatedAt: Date.now(), + } + + this.capabilities.set(key, descriptor) + return descriptor + } + + markReady(key: string, metadata?: Record) { + const current = this.capabilities.get(key) + const descriptor: CapabilityDescriptor = { + key, + state: 'ready', + metadata: metadata ?? current?.metadata, + updatedAt: Date.now(), + } + + this.capabilities.set(key, descriptor) + const waiters = this.capabilityWaiters.get(key) + if (waiters) { + for (const resolve of waiters) { + resolve(descriptor) + } + this.capabilityWaiters.delete(key) + } + + return descriptor + } + + markDegraded(key: string, metadata?: Record) { + const current = this.capabilities.get(key) + const descriptor: CapabilityDescriptor = { + key, + state: 'degraded', + metadata: metadata ?? current?.metadata, + updatedAt: Date.now(), + } + + this.capabilities.set(key, descriptor) + return descriptor + } + + withdraw(key: string, metadata?: Record) { + const current = this.capabilities.get(key) + const descriptor: CapabilityDescriptor = { + key, + state: 'withdrawn', + metadata: metadata ?? current?.metadata, + updatedAt: Date.now(), + } + + this.capabilities.set(key, descriptor) + return descriptor + } + + list() { + return [...this.capabilities.values()] + } + + isReady(key: string) { + return this.capabilities.get(key)?.state === 'ready' + } + + async waitForMany(keys: string[], timeoutMs: number = 15000) { + await Promise.all(keys.map(async key => await this.waitFor(key, timeoutMs))) + } + + async waitFor(key: string, timeoutMs: number = 15000) { + const existing = this.capabilities.get(key) + if (existing?.state === 'ready') { + return existing + } + + return await new Promise((resolve, reject) => { + let timeout: ReturnType | undefined + const onReady = (descriptor: CapabilityDescriptor) => { + if (timeout) { + clearTimeout(timeout) + } + resolve(descriptor) + } + + const waiters = this.capabilityWaiters.get(key) ?? new Set() + waiters.add(onReady) + this.capabilityWaiters.set(key, waiters) + + timeout = setTimeout(() => { + const currentWaiters = this.capabilityWaiters.get(key) + currentWaiters?.delete(onReady) + if (currentWaiters && currentWaiters.size === 0) { + this.capabilityWaiters.delete(key) + } + reject(new Error(`Capability \`${key}\` is not ready after ${timeoutMs}ms.`)) + }, timeoutMs) + }) + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts new file mode 100644 index 000000000..e856ffcba --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts @@ -0,0 +1,4 @@ +export * from './dependencies' +export * from './permissions' +export * from './resources' +export * from './sessions' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts new file mode 100644 index 000000000..239f753df --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts @@ -0,0 +1,211 @@ +import type { ModulePermissionDeclaration } from '@proj-airi/plugin-protocol/types' + +import { describe, expect, it } from 'vitest' + +import { PermissionService } from './permissions' + +describe('permissionService', () => { + it('normalizes declarations and intersects grants per area', () => { + const service = new PermissionService() + const requested: ModulePermissionDeclaration = { + apis: [ + { key: 'plugin.api.users', actions: ['invoke', 'emit'], reason: 'requested-reason' }, + ], + } + + const snapshot = service.initialize('plugin-a', requested, { + grant: { + apis: [ + { key: 'plugin.api.*', actions: ['invoke'] }, + { key: 'plugin.api.audit', actions: ['emit'] }, + ], + }, + }) + + expect(snapshot.requested.resources).toEqual([]) + expect(snapshot.granted.apis).toEqual([ + { + key: 'plugin.api.users', + actions: ['invoke'], + reason: 'requested-reason', + }, + ]) + }) + + it('merges persisted and incremental grants while preserving requested descriptors', () => { + const service = new PermissionService() + const requested: ModulePermissionDeclaration = { + resources: [ + { + key: 'plugin.resource.settings', + actions: ['read', 'write'], + label: 'Settings', + metadata: { source: 'manifest' }, + }, + ], + } + + const initialized = service.initialize('plugin-b', requested, { + persisted: { + resources: [ + { key: 'plugin.resource.settings', actions: ['read'] }, + ], + }, + grant: {}, + }) + + expect(initialized.granted.resources).toEqual([ + { + key: 'plugin.resource.settings', + actions: ['read'], + label: 'Settings', + metadata: { source: 'manifest' }, + }, + ]) + + const updated = service.grant('plugin-b', { + resources: [ + { key: 'plugin.resource.settings', actions: ['write'] }, + ], + }) + + expect(updated.granted.resources).toEqual([ + { + key: 'plugin.resource.settings', + actions: ['read', 'write'], + label: 'Settings', + metadata: { source: 'manifest' }, + }, + ]) + expect(service.isAllowed('plugin-b', 'resources', 'write', 'plugin.resource.settings')).toBe(true) + }) + + it('extends the requested baseline before granting runtime-declared permissions', () => { + const service = new PermissionService() + const initialized = service.initialize('plugin-runtime', {}, { + grant: {}, + }) + + expect(initialized.requested.apis).toEqual([]) + + const declared = service.declare('plugin-runtime', { + apis: [ + { + key: 'plugin.api.runtime', + actions: ['invoke'], + reason: 'Late-bound runtime capability', + }, + ], + }) + + expect(declared.requested.apis).toEqual([ + { + key: 'plugin.api.runtime', + actions: ['invoke'], + reason: 'Late-bound runtime capability', + }, + ]) + expect(declared.granted.apis).toEqual([]) + + const granted = service.grant('plugin-runtime', { + apis: [ + { + key: 'plugin.api.runtime', + actions: ['invoke'], + }, + ], + }) + + expect(granted.granted.apis).toEqual([ + { + key: 'plugin.api.runtime', + actions: ['invoke'], + reason: 'Late-bound runtime capability', + }, + ]) + expect(service.isAllowed('plugin-runtime', 'apis', 'invoke', 'plugin.api.runtime')).toBe(true) + }) + + it('stores the narrower granted key when a wildcard request is only partially approved', () => { + const service = new PermissionService() + const requested: ModulePermissionDeclaration = { + resources: [ + { + key: 'plugin.resource.*', + actions: ['read'], + reason: 'Read plugin resources', + }, + ], + } + + // This occurs when a plugin asks for a wildcard scope up front, but the host policy + // or user approval flow only accepts one concrete resource key from that set. + // The bug was caused by the intersection logic always writing back the requested key, + // which meant `plugin.resource.*` stayed in the granted snapshot even though the host + // only approved `plugin.resource.settings`. + const snapshot = service.initialize('plugin-c', requested, { + grant: { + resources: [ + { key: 'plugin.resource.settings', actions: ['read'] }, + ], + }, + }) + + // We expect the effective grant to contain only the host-approved concrete key. + // That way later `isAllowed(...)` checks reflect the actual approval boundary instead + // of silently widening it back to the plugin's original wildcard request. + expect(snapshot.granted.resources).toEqual([ + { + key: 'plugin.resource.settings', + actions: ['read'], + reason: 'Read plugin resources', + }, + ]) + expect(service.isAllowed('plugin-c', 'resources', 'read', 'plugin.resource.settings')).toBe(true) + expect(service.isAllowed('plugin-c', 'resources', 'read', 'plugin.resource.secrets')).toBe(false) + }) + + it('splits a broad request into per-grant scopes when the host approves disjoint keys', () => { + const service = new PermissionService() + const requested: ModulePermissionDeclaration = { + apis: [ + { key: 'plugin.api.*', actions: ['invoke', 'emit'], reason: 'Use selected APIs' }, + ], + } + + // This occurs when a plugin requests one broad API namespace, but the host grants a + // selective subset across different keys and actions. That is a normal outcome for a + // least-privilege resolver that narrows access based on policy, user consent, or both. + // The old behavior collapsed every overlap back into the wildcard request, which let one + // narrow approval accidentally authorize unrelated keys within the same namespace. + const snapshot = service.initialize('plugin-d', requested, { + grant: { + apis: [ + { key: 'plugin.api.users', actions: ['invoke'] }, + { key: 'plugin.api.audit', actions: ['emit'] }, + ], + }, + }) + + // We expect one granted scope per concrete approval because each grant represents a + // separate host decision. Preserving those concrete keys keeps later permission checks + // aligned with the host's intent: users invoke `plugin.api.users`, emit to + // `plugin.api.audit`, and nothing else is implied. + expect(snapshot.granted.apis).toEqual([ + { + key: 'plugin.api.users', + actions: ['invoke'], + reason: 'Use selected APIs', + }, + { + key: 'plugin.api.audit', + actions: ['emit'], + reason: 'Use selected APIs', + }, + ]) + expect(service.isAllowed('plugin-d', 'apis', 'invoke', 'plugin.api.users')).toBe(true) + expect(service.isAllowed('plugin-d', 'apis', 'emit', 'plugin.api.audit')).toBe(true) + expect(service.isAllowed('plugin-d', 'apis', 'emit', 'plugin.api.users')).toBe(false) + expect(service.isAllowed('plugin-d', 'apis', 'invoke', 'plugin.api.billing')).toBe(false) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts new file mode 100644 index 000000000..8b325c269 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts @@ -0,0 +1,331 @@ +import type { + ModulePermissionArea, + ModulePermissionDeclaration, + ModulePermissionGrant, +} from '@proj-airi/plugin-protocol/types' + +interface PermissionSnapshot { + requested: ModulePermissionDeclaration + granted: ModulePermissionGrant + revision: number +} + +interface PermissionScope { + key: string + actions: Action[] +} + +function hasAction(actions: Action[], action: string): action is Action { + return actions.includes(action as Action) +} + +function matchKey(pattern: string, target: string) { + if (pattern === '*') { + return true + } + + if (pattern.endsWith('*')) { + return target.startsWith(pattern.slice(0, -1)) + } + + return pattern === target +} + +function getIntersectionKey(left: string, right: string) { + if (matchKey(left, right)) { + return right + } + + if (matchKey(right, left)) { + return left + } + + return undefined +} + +function normalizeDeclaration(declaration?: ModulePermissionDeclaration | null): ModulePermissionDeclaration { + return { + apis: declaration?.apis ?? [], + resources: declaration?.resources ?? [], + capabilities: declaration?.capabilities ?? [], + processors: declaration?.processors ?? [], + pipelines: declaration?.pipelines ?? [], + } +} + +/** + * Computes the effective permission scopes for one area by intersecting what the plugin asked for + * with what the host actually approved. + * + * Use cases: + * - Requested `plugin.api.users` + granted `plugin.api.*` => effective key `plugin.api.users` + * - Requested `plugin.api.*` + granted `plugin.api.users` => effective key `plugin.api.users` + * - Requested actions `['invoke', 'emit']` + granted actions `['invoke']` => effective actions `['invoke']` + * + * The returned scopes always stay within both boundaries: + * - never broader than the plugin request + * - never broader than the host grant + * + * We also preserve request-side metadata such as `reason` and `label`, because those describe why + * the plugin asked for the permission and should remain visible in the effective snapshot. + */ +function intersectPermissionScopes( + requested: T[] | undefined, + granted: T[] | undefined, +): T[] { + if (!requested?.length || !granted?.length) { + return [] + } + + const result = new Map() + for (const requestedSpec of requested) { + // Example: + // - requestedSpec.key = 'plugin.api.*' + // - requestedSpec.actions = ['invoke', 'emit'] + // + // We compare this one requested scope against every host-approved candidate to find the + // concrete overlaps that should become effective grants. + for (const candidate of granted) { + // Example candidate: + // - candidate.key = 'plugin.api.users' + // - candidate.actions = ['invoke'] + // + // `getIntersectionKey(...)` returns the narrower key shared by both scopes: + // - requested 'plugin.api.*' + granted 'plugin.api.users' => 'plugin.api.users' + // - requested 'plugin.api.users' + granted 'plugin.api.*' => 'plugin.api.users' + // - requested 'plugin.api.users' + granted 'plugin.api.audit' => undefined + const intersectionKey = getIntersectionKey(requestedSpec.key, candidate.key) + if (!intersectionKey) { + // No shared boundary means this host grant does not authorize anything from this request. + // Example: + // - requestedSpec.key = 'plugin.api.users' + // - candidate.key = 'plugin.api.audit' + // Result: skip this pair completely. + continue + } + + const actions = new Set() + for (const action of candidate.actions) { + // We only keep actions present in both lists. + // Example: + // - requestedSpec.actions = ['invoke', 'emit'] + // - candidate.actions = ['invoke'] + // Result: actions = ['invoke'] + // + // If candidate.actions contains an action the plugin never requested, such as 'manage', + // we must not widen access, so that action is dropped here. + if (hasAction(requestedSpec.actions, action)) { + actions.add(action) + } + } + + if (actions.size === 0) { + // Keys overlap, but the actions do not. + // Example: + // - requestedSpec.key = 'plugin.api.users', requestedSpec.actions = ['emit'] + // - candidate.key = 'plugin.api.users', candidate.actions = ['invoke'] + // Result: still not allowed, because there is no shared action. + continue + } + + const existing = result.get(intersectionKey) + const mergedActions = new Set(existing?.actions ?? []) + for (const action of actions) { + // Multiple host grants can contribute actions to the same effective key. + // Example: + // - candidate #1 grants 'plugin.api.users' -> ['invoke'] + // - candidate #2 grants 'plugin.api.users' -> ['emit'] + // Result after merging: 'plugin.api.users' -> ['invoke', 'emit'] + mergedActions.add(action) + } + + result.set(intersectionKey, { + // Preserve the request-side descriptor while narrowing the key/actions to the true overlap. + // Example output: + // { + // key: 'plugin.api.users', + // actions: ['invoke'], + // reason: requestedSpec.reason, + // } + ...requestedSpec, + ...existing, + key: intersectionKey, + actions: [...mergedActions], + } as T) + } + } + + return [...result.values()] +} + +function intersectPermissions( + requested: ModulePermissionDeclaration, + grant: ModulePermissionGrant, +): ModulePermissionGrant { + return { + apis: intersectPermissionScopes(requested.apis, grant.apis), + resources: intersectPermissionScopes(requested.resources, grant.resources), + capabilities: intersectPermissionScopes(requested.capabilities, grant.capabilities), + processors: intersectPermissionScopes(requested.processors, grant.processors), + pipelines: intersectPermissionScopes(requested.pipelines, grant.pipelines), + } +} + +/** + * Merges two scope lists for the same permission area by key, unioning actions for duplicate keys. + * + * Use cases: + * - persisted `plugin.resource.settings -> ['read']` + incoming `plugin.resource.settings -> ['write']` + * => `plugin.resource.settings -> ['read', 'write']` + * - current `plugin.api.users -> ['invoke']` + incoming `plugin.api.audit -> ['emit']` + * => both scopes remain in the result because their keys differ + * + * This is used when permission state needs accumulation rather than narrowing, such as: + * - combining persisted grants with new grants + * - extending requested declarations with newly runtime-declared scopes + */ +function mergePermissionScopes( + current: T[] | undefined, + incoming: T[] | undefined, +): T[] { + const map = new Map() + + for (const list of [current ?? [], incoming ?? []]) { + // We process the existing list first, then layer the incoming list on top. + // Example: + // - current = [{ key: 'plugin.resource.settings', actions: ['read'] }] + // - incoming = [{ key: 'plugin.resource.settings', actions: ['write'] }] + // + // The first pass seeds the map with ['read']; the second pass extends it to + // ['read', 'write'] for the same key. + for (const spec of list) { + // `spec` is one concrete scope such as: + // - { key: 'plugin.api.users', actions: ['invoke'] } + // or + // - { key: 'plugin.resource.settings', actions: ['write'] } + const previous = map.get(spec.key) + const actions = new Set(previous?.actions ?? []) + for (const action of spec.actions) { + // Actions are unioned, not replaced. + // Example: + // - previous.actions = ['read'] + // - spec.actions = ['write'] + // Result: ['read', 'write'] + // + // If the same action appears twice, Set keeps it unique. + actions.add(action) + } + map.set(spec.key, { + // For duplicate keys, later fields from `spec` can refine metadata while actions remain + // cumulative. For distinct keys, this simply inserts a new scope entry. + ...previous, + ...spec, + actions: [...actions], + } as T) + } + } + + // The map now holds one merged scope per key. + return [...map.values()] +} + +function mergePermissions(current: ModulePermissionGrant, incoming: ModulePermissionGrant): ModulePermissionGrant { + return { + apis: mergePermissionScopes(current.apis, incoming.apis), + resources: mergePermissionScopes(current.resources, incoming.resources), + capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities), + processors: mergePermissionScopes(current.processors, incoming.processors), + pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines), + } +} + +function mergePermissionDeclarations( + current: ModulePermissionDeclaration, + incoming: ModulePermissionDeclaration, +): ModulePermissionDeclaration { + return { + apis: mergePermissionScopes(current.apis, incoming.apis), + resources: mergePermissionScopes(current.resources, incoming.resources), + capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities), + processors: mergePermissionScopes(current.processors, incoming.processors), + pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines), + } +} + +export class PermissionService { + private readonly store = new Map() + + initialize( + pluginId: string, + requestedDeclaration: ModulePermissionDeclaration, + options?: { + grant?: ModulePermissionGrant + persisted?: ModulePermissionGrant + }, + ) { + const requested = normalizeDeclaration(requestedDeclaration) + const persisted = options?.persisted ?? {} + const explicitGrant = options?.grant ?? requested + const mergedGrant = mergePermissions(persisted, explicitGrant) + const granted = intersectPermissions(requested, mergedGrant) + const previousRevision = this.store.get(pluginId)?.revision ?? 0 + const snapshot: PermissionSnapshot = { + requested, + granted, + revision: previousRevision + 1, + } + + this.store.set(pluginId, snapshot) + return snapshot + } + + declare(pluginId: string, requestedDeclaration: ModulePermissionDeclaration) { + const existing = this.store.get(pluginId) + if (!existing) { + throw new Error(`Cannot declare permissions for unknown plugin "${pluginId}".`) + } + + const requested = normalizeDeclaration(requestedDeclaration) + const snapshot: PermissionSnapshot = { + requested: mergePermissionDeclarations(existing.requested, requested), + granted: existing.granted, + revision: existing.revision + 1, + } + + this.store.set(pluginId, snapshot) + return snapshot + } + + grant(pluginId: string, grant: ModulePermissionGrant) { + const existing = this.store.get(pluginId) + if (!existing) { + throw new Error(`Cannot grant permissions to unknown plugin "${pluginId}".`) + } + + const mergedGranted = mergePermissions(existing.granted, grant) + const snapshot: PermissionSnapshot = { + requested: existing.requested, + granted: intersectPermissions(existing.requested, mergedGranted), + revision: existing.revision + 1, + } + this.store.set(pluginId, snapshot) + return snapshot + } + + get(pluginId: string) { + return this.store.get(pluginId) + } + + isAllowed(pluginId: string, area: ModulePermissionArea, action: string, key: string) { + const snapshot = this.store.get(pluginId) + if (!snapshot) { + return false + } + + const scopes = snapshot.granted[area] ?? [] + return scopes.some(scope => + matchKey(scope.key, key) + && hasAction(scope.actions, action), + ) + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.test.ts new file mode 100644 index 000000000..1f43db0bd --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ResourceService } from './resources' + +describe('resourceService', () => { + it('should prefer resolver values over stored values', async () => { + const service = new ResourceService() + const resolver = vi.fn(async () => 'from-resolver') + + service.setValue('resource:theme', 'from-value') + service.setResolver('resource:theme', resolver) + + await expect(service.get('resource:theme')).resolves.toBe('from-resolver') + expect(resolver).toHaveBeenCalledOnce() + expect(service.has('resource:theme')).toBe(true) + }) + + it('should return stored values and fallbacks when a resolver is not registered', async () => { + const service = new ResourceService() + + service.setValue('resource:locale', 'en-US') + + await expect(service.get('resource:locale')).resolves.toBe('en-US') + await expect(service.get('resource:missing', 'fallback')).resolves.toBe('fallback') + expect(service.has('resource:missing')).toBe(false) + }) + + it('should stop resolving resources after the resolver and value are removed', async () => { + const service = new ResourceService() + + service.setResolver('resource:user', () => ({ id: 'resolver' })) + service.setValue('resource:user', { id: 'value' }) + + service.removeResolver('resource:user') + await expect(service.get('resource:user')).resolves.toEqual({ id: 'value' }) + + service.removeValue('resource:user') + await expect(service.get('resource:user')).resolves.toBeUndefined() + expect(service.has('resource:user')).toBe(false) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts new file mode 100644 index 000000000..67199b918 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts @@ -0,0 +1,93 @@ +export type ResourceResolver = () => Promise | T + +/** + * Stores resources either as eager values or lazy resolver functions. + * + * Lookup order is: + * 1. resolver registered with `setResolver` + * 2. value registered with `setValue` + * 3. optional fallback passed to `get` + * + * Example: + * ```ts + * const service = new ResourceService() + * service.setValue('resource:locale', 'en-US') + * service.setResolver('resource:theme', async () => 'dark') + * + * await service.get('resource:locale') // 'en-US' + * await service.get('resource:theme') // 'dark' + * await service.get('resource:missing', 'fallback') // 'fallback' + * ``` + */ +export class ResourceService { + private readonly resolvers = new Map>() + private readonly values = new Map() + + /** + * Registers a lazy resource provider for `key`. + * + * The resolver is called every time `get(key)` is executed, and its result + * takes precedence over any value previously stored with `setValue(key, ...)`. + */ + setResolver(key: string, resolver: ResourceResolver) { + this.resolvers.set(key, resolver as ResourceResolver) + } + + /** + * Removes the lazy resolver for `key`. + * + * If a value still exists for the same key, subsequent `get(key)` calls fall + * back to that stored value. + */ + removeResolver(key: string) { + this.resolvers.delete(key) + } + + /** + * Stores an eager value for `key`. + * + * Use this when the resource is already available and does not need to be + * computed on demand. This value is only returned when no resolver is + * registered for the same key. + */ + setValue(key: string, value: T) { + this.values.set(key, value) + } + + /** + * Removes the stored value for `key`. + * + * If a resolver still exists for the same key, `get(key)` continues to + * resolve through that resolver. + */ + removeValue(key: string) { + this.values.delete(key) + } + + /** + * Returns whether any resolver or stored value is registered for `key`. + */ + has(key: string) { + return this.resolvers.has(key) || this.values.has(key) + } + + /** + * Resolves a resource by key. + * + * Resolution order is resolver -> stored value -> fallback. + * The optional fallback is only used when neither a resolver nor a stored + * value has been registered for the key. + */ + async get(key: string, fallback?: T): Promise { + const resolver = this.resolvers.get(key) + if (resolver) { + return await resolver() as T + } + + if (this.values.has(key)) { + return this.values.get(key) as T + } + + return fallback + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts new file mode 100644 index 000000000..93088a372 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +import { PluginSessionService } from './sessions' + +vi.mock('nanoid/non-secure', () => ({ + nanoid: vi + .fn() + .mockReturnValueOnce('session-a') + .mockReturnValueOnce('session-b'), +})) + +interface TestSession { + id: string + state: 'active' | 'closed' +} + +describe('pluginSessionService', () => { + it('registers, lists, gets, and removes sessions by id', () => { + const service = new PluginSessionService() + const firstSession: TestSession = { id: 'session-1', state: 'active' } + const secondSession: TestSession = { id: 'session-2', state: 'closed' } + + expect(service.list()).toEqual([]) + expect(service.get('missing')).toBeUndefined() + + expect(service.register(firstSession)).toBe(firstSession) + expect(service.register(secondSession)).toBe(secondSession) + expect(service.list()).toEqual([firstSession, secondSession]) + expect(service.get('session-1')).toBe(firstSession) + expect(service.get('session-2')).toBe(secondSession) + + expect(service.remove('session-1')).toBe(firstSession) + expect(service.list()).toEqual([secondSession]) + expect(service.get('session-1')).toBeUndefined() + expect(service.remove('session-1')).toBeUndefined() + }) + + it('generates random session ids and incrementing module identities with sanitized plugin names', () => { + const service = new PluginSessionService() + + expect(service.nextSessionIdentity(' demo-plugin ')).toEqual({ + index: 0, + sessionId: 'plugin-session-session-a', + moduleIdentity: { + id: 'demo-plugin-0', + kind: 'plugin', + plugin: { + id: 'demo-plugin', + }, + }, + }) + + expect(service.nextSessionIdentity(' ')).toEqual({ + index: 1, + sessionId: 'plugin-session-session-b', + moduleIdentity: { + id: 'plugin-1', + kind: 'plugin', + plugin: { + id: 'plugin', + }, + }, + }) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts new file mode 100644 index 000000000..d0e82fb6d --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts @@ -0,0 +1,54 @@ +import type { ModuleIdentity } from '../../../shared/types' + +import { nanoid } from 'nanoid/non-secure' + +function createModuleIdentity(name: string, index: number): ModuleIdentity { + const sanitizedName = name.trim() || 'plugin' + + return { + id: `${sanitizedName}-${index}`, + kind: 'plugin', + plugin: { + id: sanitizedName, + }, + } +} + +export class PluginSessionService { + private readonly sessions = new Map() + private sessionCounter = 0 + + list() { + return [...this.sessions.values()] + } + + get(sessionId: string) { + return this.sessions.get(sessionId) + } + + register(session: TSession) { + this.sessions.set(session.id, session) + return session + } + + remove(sessionId: string) { + const session = this.sessions.get(sessionId) + if (!session) { + return undefined + } + + this.sessions.delete(session.id) + return session + } + + nextSessionIdentity(name: string) { + const index = this.sessionCounter + this.sessionCounter += 1 + + return { + index, + sessionId: `plugin-session-${nanoid()}`, + moduleIdentity: createModuleIdentity(name, index), + } + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts index 5b144d552..0f199969f 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts @@ -5,6 +5,7 @@ import type { PluginTransport } from '../../transports' import { createContext } from '@moeru/eventa' export * from '../../core' +export * from '../../shared' export * from '../../transports' export function createPluginContext(transport: PluginTransport): EventContext { diff --git a/packages/plugin-sdk/src/plugin-host/shared.ts b/packages/plugin-sdk/src/plugin-host/shared.ts deleted file mode 100644 index 336ce12bb..000000000 --- a/packages/plugin-sdk/src/plugin-host/shared.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/packages/plugin-sdk/src/plugin-host/shared/index.ts b/packages/plugin-sdk/src/plugin-host/shared/index.ts new file mode 100644 index 000000000..c9f6f047d --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/index.ts @@ -0,0 +1 @@ +export * from './types' diff --git a/packages/plugin-sdk/src/plugin-host/shared/types.ts b/packages/plugin-sdk/src/plugin-host/shared/types.ts new file mode 100644 index 000000000..25900d182 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/types.ts @@ -0,0 +1,151 @@ +import type { + ProtocolEvents, + ModuleConfigEnvelope as ProtocolModuleConfigEnvelope, + ModuleIdentity as ProtocolModuleIdentity, + ModulePermissionDeclaration as ProtocolModulePermissionDeclaration, + ModulePermissionGrant as ProtocolModulePermissionGrant, + ModulePhase as ProtocolModulePhase, + PluginIdentity as ProtocolPluginIdentity, +} from '@proj-airi/plugin-protocol/types' + +import type { PluginTransport } from '../transports' + +import { + array, + boolean, + literal, + number, + object, + optional, + picklist, + record, + string, + union, +} from 'valibot' + +export type PluginRuntime = 'electron' | 'node' | 'web' + +export type ModulePhase = ProtocolModulePhase + +export type PluginSessionPhase + = | 'loading' + | 'loaded' + | 'authenticating' + | 'authenticated' + | 'waiting-deps' + | ModulePhase + | 'stopped' + +export type PluginIdentity = ProtocolPluginIdentity + +export type ModuleIdentity = ProtocolModuleIdentity + +export type ModuleConfigEnvelope> = ProtocolModuleConfigEnvelope + +export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request'] + +export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result'] + +export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration + +export type ModulePermissionGrant = ProtocolModulePermissionGrant + +export interface ManifestV1 { + apiVersion: 'v1' + kind: 'manifest.plugin.airi.moeru.ai' + name: string + permissions?: ModulePermissionDeclaration + entrypoints: { + default?: string + electron?: string + node?: string + web?: string + } +} + +const localizableSchema = union([ + string(), + object({ + key: string(), + fallback: optional(string()), + params: optional(record(string(), union([string(), number(), boolean()]))), + }), +]) + +export const manifestV1Schema = object({ + apiVersion: literal('v1'), + kind: literal('manifest.plugin.airi.moeru.ai'), + name: string(), + permissions: optional(object({ + apis: optional(array(object({ + key: string(), + actions: array(picklist(['invoke', 'emit'])), + reason: optional(localizableSchema), + label: optional(localizableSchema), + required: optional(boolean()), + }))), + resources: optional(array(object({ + key: string(), + actions: array(picklist(['read', 'write', 'subscribe'])), + reason: optional(localizableSchema), + label: optional(localizableSchema), + required: optional(boolean()), + }))), + capabilities: optional(array(object({ + key: string(), + actions: array(picklist(['wait', 'snapshot'])), + reason: optional(localizableSchema), + label: optional(localizableSchema), + required: optional(boolean()), + }))), + processors: optional(array(object({ + key: string(), + actions: array(picklist(['register', 'execute', 'manage'])), + reason: optional(localizableSchema), + label: optional(localizableSchema), + required: optional(boolean()), + }))), + pipelines: optional(array(object({ + key: string(), + actions: array(picklist(['hook', 'process', 'emit', 'manage'])), + reason: optional(localizableSchema), + label: optional(localizableSchema), + required: optional(boolean()), + }))), + })), + entrypoints: object({ + default: optional(string()), + electron: optional(string()), + node: optional(string()), + web: optional(string()), + }), +}) + +export interface PluginLoadOptions { + cwd?: string + runtime?: PluginRuntime +} + +export interface PluginHostOptions { + runtime?: PluginRuntime + transport?: PluginTransport + protocolVersion?: string + apiVersion?: string + supportedProtocolVersions?: string[] + supportedApiVersions?: string[] + permissionResolver?: (payload: { + identity: ModuleIdentity + manifest: ManifestV1 + requested: ModulePermissionDeclaration + persisted?: ModulePermissionGrant + }) => ModulePermissionGrant | Promise +} + +export interface PluginStartOptions { + cwd?: string + runtime?: PluginRuntime + requireConfiguration?: boolean + compatibility?: Omit + requiredCapabilities?: string[] + capabilityWaitTimeoutMs?: number +} diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts index 510366e2d..cae3ae550 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts @@ -7,10 +7,12 @@ export interface CapabilityDescriptor { updatedAt: number } +export const protocolCapabilityWaitEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait' export const protocolCapabilityWait = defineInvokeEventa( - 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', + protocolCapabilityWaitEventName, ) +export const protocolCapabilitySnapshotEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot' export const protocolCapabilitySnapshot = defineInvokeEventa( - 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot', + protocolCapabilitySnapshotEventName, ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 163faa509..a2f008e6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2173,6 +2173,9 @@ importers: '@proj-airi/server-shared': specifier: workspace:* version: link:../server-shared + nanoid: + specifier: 'catalog:' + version: 5.1.6 valibot: specifier: ^1.2.0 version: 1.2.0(typescript@5.9.3)