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 <noreply@anthropic.com>
This commit is contained in:
Neko
2026-03-19 23:50:04 +08:00
committed by GitHub
parent b69d968fdd
commit a2e134d498
28 changed files with 2433 additions and 340 deletions
@@ -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.
@@ -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"
}
@@ -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' },
}),
]))
})
})
@@ -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 }>> | Array<{ name: string }>) => void
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
announceCapability: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
@@ -48,6 +50,18 @@ interface CapabilityAwarePluginHost extends PluginHost {
metadata?: Record<string, unknown>
updatedAt: number
}
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
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<string>, 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<typeof useLogg>): Promise<ManifestEntry[]> {
await mkdir(dir, { recursive: true })
const entries = await readdir(dir, { withFileTypes: true })
@@ -78,10 +112,35 @@ async function loadManifestsFrom(dir: string, log: ReturnType<typeof useLogg>):
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<PluginHostService> {
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.
+175 -1
View File
@@ -343,6 +343,57 @@ export interface ModuleCapability {
metadata?: Record<string, unknown>
}
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<string, unknown>
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<C = undefined> {
name: string
identity: ModuleIdentity
possibleEvents: Array<(keyof ProtocolEvents<C>)>
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<RegistryModulesHealth
export const registryModulesHealthHealthy = defineEventa<RegistryModulesHealthHealthyEvent>('registry:modules:health:healthy')
export const error = defineEventa<ErrorEvent>('error')
/** Permission-check failure event. See `ModulePermissionError`. */
export const errorPermission = defineEventa<ErrorPermissionEvent>('error:permission')
export const moduleAnnounce = defineEventa<ModuleAnnounceEvent>('module:announce')
export const moduleAnnounced = defineEventa<ModuleAnnouncedEvent>('module:announced')
export const moduleDeAnnounced = defineEventa<ModuleDeAnnouncedEvent>('module:de-announced')
/** Permission declaration lifecycle event. See `ModulePermissionsDeclareEvent`. */
export const modulePermissionsDeclare = defineEventa<ModulePermissionsDeclareEvent>('module:permissions:declare')
/** Permission request lifecycle event. See `ModulePermissionsRequestEvent`. */
export const modulePermissionsRequest = defineEventa<ModulePermissionsRequestEvent>('module:permissions:request')
/** Permission grant lifecycle event. See `ModulePermissionsGrantedEvent`. */
export const modulePermissionsGranted = defineEventa<ModulePermissionsGrantedEvent>('module:permissions:granted')
/** Permission denial lifecycle event. See `ModulePermissionsDeniedEvent`. */
export const modulePermissionsDenied = defineEventa<ModulePermissionsDeniedEvent>('module:permissions:denied')
/** Current permission snapshot event. See `ModulePermissionsCurrentEvent`. */
export const modulePermissionsCurrent = defineEventa<ModulePermissionsCurrentEvent>('module:permissions:current')
export const modulePrepared = defineEventa<ModulePreparedEvent>('module:prepared')
export const moduleConfigurationNeeded = defineEventa<ModuleConfigurationNeededEvent>('module:configuration:needed')
export const moduleStatus = defineEventa<ModuleStatusEvent>('module:status')
@@ -906,6 +1074,7 @@ export const contextUpdate = defineEventa<ContextUpdateEvent>('context:update')
// https://www.reddit.com/r/typescript/comments/1064ibt/a_little_hack_for_creating_extensible/
export interface ProtocolEvents<C = undefined> {
'error': ErrorEvent
'error:permission': ErrorPermissionEvent
'module:authenticate': ModuleAuthenticateEvent
'module:authenticated': ModuleAuthenticatedEvent
@@ -940,6 +1109,11 @@ export interface ProtocolEvents<C = undefined> {
* module:announced or module:de-announced, or registry:modules:sync and registry:modules:health:* events for more reliable discovery and tracking.
*/
'module:announce': ModuleAnnounceEvent<C>
'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.
*/
+1
View File
@@ -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"
}
@@ -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<string, unknown> },
) {
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<string, unknown> }> = []
const currentEvents: Array<{ body?: Record<string, unknown> }> = []
const requestEvents: Array<{ body?: Record<string, unknown> }> = []
const grantedEvents: Array<{ body?: Record<string, unknown> }> = []
session.channels.host.on(modulePermissionsDeclare, payload => declareEvents.push(payload as unknown as { body?: Record<string, unknown> }))
session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record<string, unknown> }))
session.channels.host.on(modulePermissionsRequest, payload => requestEvents.push(payload as unknown as { body?: Record<string, unknown> }))
session.channels.host.on(modulePermissionsGranted, payload => grantedEvents.push(payload as unknown as { body?: Record<string, unknown> }))
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<string, unknown> }> = []
const currentEvents: Array<{ body?: Record<string, unknown> }> = []
session.channels.host.on(modulePermissionsDenied, payload => deniedEvents.push(payload as unknown as { body?: Record<string, unknown> }))
session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record<string, unknown> }))
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: [],
})
})
})
+385 -315
View File
@@ -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<typeof definePlugin> {
return typeof value === 'object'
&& value !== null
&& 'setup' in value
&& typeof (value as { setup?: unknown }).setup === 'function'
}
async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
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<typeof definePlugin> }).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<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
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<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
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<string>()
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<typeof createPluginContext>
}
apis: ReturnType<typeof createApis>
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<string, PluginHostSession>()
private readonly sessionService = new PluginSessionService<PluginHostSession>()
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<string, CapabilityDescriptor>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
private providersListResolver: () => Promise<Array<{ name: string }>> | 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<string, ModulePermissionGrant>()
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<PluginHostSession> {
@@ -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<Array<{ name: string }>>(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<PluginHostSession> {
// `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 }>> | 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<T>(key: string, resolver: () => Promise<T> | T) {
this.resources.setResolver(key, resolver)
}
setResourceValue<T>(key: string, value: T) {
this.resources.setValue(key, value)
}
announceCapability(key: string, metadata?: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<CapabilityDescriptor>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | 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.<runtime>`
* 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.<runtime>`, `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)
}
}
@@ -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'
@@ -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<any, any> {
switch (transport.kind) {
@@ -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<typeof definePlugin> {
return typeof value === 'object'
&& value !== null
&& 'setup' in value
&& typeof (value as { setup?: unknown }).setup === 'function'
}
async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
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<typeof definePlugin> }).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.<runtime>`
* 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.<runtime>`, `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)
}
}
@@ -0,0 +1 @@
export * from './fs'
@@ -0,0 +1 @@
export * from './services'
@@ -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()
})
})
@@ -0,0 +1,108 @@
import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol'
export class DependencyService {
private readonly capabilities = new Map<string, CapabilityDescriptor>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
announce(key: string, metadata?: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<CapabilityDescriptor>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | 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)
})
}
}
@@ -0,0 +1,4 @@
export * from './dependencies'
export * from './permissions'
export * from './resources'
export * from './sessions'
@@ -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)
})
})
@@ -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<Action extends string = string> {
key: string
actions: Action[]
}
function hasAction<Action extends string>(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<T extends PermissionScope>(
requested: T[] | undefined,
granted: T[] | undefined,
): T[] {
if (!requested?.length || !granted?.length) {
return []
}
const result = new Map<string, T>()
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<T['actions'][number]>()
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<T extends PermissionScope>(
current: T[] | undefined,
incoming: T[] | undefined,
): T[] {
const map = new Map<string, T>()
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<string, PermissionSnapshot>()
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),
)
}
}
@@ -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)
})
})
@@ -0,0 +1,93 @@
export type ResourceResolver<T> = () => Promise<T> | 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<string, ResourceResolver<unknown>>()
private readonly values = new Map<string, unknown>()
/**
* 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<T>(key: string, resolver: ResourceResolver<T>) {
this.resolvers.set(key, resolver as ResourceResolver<unknown>)
}
/**
* 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<T>(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<T>(key: string, fallback?: T): Promise<T | undefined> {
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
}
}
@@ -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<TestSession>()
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<TestSession>()
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',
},
},
})
})
})
@@ -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<TSession extends { id: string }> {
private readonly sessions = new Map<string, TSession>()
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),
}
}
}
@@ -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<any, any> {
@@ -1 +0,0 @@
export {}
@@ -0,0 +1 @@
export * from './types'
@@ -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<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
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<ModulePermissionGrant>
}
export interface PluginStartOptions {
cwd?: string
runtime?: PluginRuntime
requireConfiguration?: boolean
compatibility?: Omit<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
requiredCapabilities?: string[]
capabilityWaitTimeoutMs?: number
}
@@ -7,10 +7,12 @@ export interface CapabilityDescriptor {
updatedAt: number
}
export const protocolCapabilityWaitEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait'
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, { key: string, timeoutMs?: number }>(
'proj-airi:plugin-sdk:apis:protocol:capabilities:wait',
protocolCapabilityWaitEventName,
)
export const protocolCapabilitySnapshotEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot'
export const protocolCapabilitySnapshot = defineInvokeEventa<CapabilityDescriptor[]>(
'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot',
protocolCapabilitySnapshotEventName,
)
+3
View File
@@ -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)