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:
+2
@@ -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.
|
||||
|
||||
+24
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user