diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index e87403f59..5db11b716 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -33,7 +33,7 @@ import { setupServerChannel } from './services/airi/channel-server' import { setupGodotStageManager } from './services/airi/godot-stage' import { setupBuiltInServer } from './services/airi/http-server' import { setupMcpStdioManager } from './services/airi/mcp-servers' -import { setupPluginHost } from './services/airi/plugins' +import { setupExtensionHost } from './services/airi/plugins' import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge' import { setupAutoUpdater } from './services/electron/auto-updater' import { setupGlobalShortcutService } from './services/electron/global-shortcut' @@ -171,7 +171,7 @@ app.whenReady().then(async () => { const pluginHost = injeca.provide('modules:plugin-host', { dependsOn: { serverChannel, widgetsManager }, - build: ({ dependsOn }) => setupPluginHost(dependsOn), + build: ({ dependsOn }) => setupExtensionHost(dependsOn), }) const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService()) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md index a7a722f1a..04b096264 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md @@ -1,32 +1,31 @@ # Devtools Sample Plugin -This sample plugin is for validating plugin host behavior in the **Plugin Host Inspector** page. +This sample extension is for validating extension host behavior in the **Extension Host Inspector** page. ## Files -- `plugin.airi.json`: plugin manifest (`ManifestV1`) -- `devtools-sample-plugin.mjs`: plugin implementation +- `extension.airi.json`: extension manifest (`ExtensionManifestV1`) +- `devtools-sample-plugin.mjs`: extension 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. +The manifest declares the extension entrypoint used by the host inspector sample. ## How to use 1. Open `/devtools/plugin-host` in Stage Tamagotchi. 2. Note the `registry.root` path from the page. 3. Copy both files into that `registry.root` directory. -4. In Plugin Host Inspector: +4. In Extension Host Inspector: - click `Refresh` - find `devtools-sample-plugin` - click `Enable` - click `Load` (or `Load Enabled`) 5. Confirm: - - plugin appears as `loaded` + - extension appears as `loaded` - session phase becomes `ready` - capability list is visible -## What this plugin does +## What this extension does -- `init`: logs startup in renderer/main console. -- `setupModules`: calls `apis.providers.listProviders()` and logs provider names. +- `setup`: logs startup in renderer/main console. It does not mutate app state; it is safe for lifecycle verification. diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs index 14af14850..d30adf07a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs @@ -1,3 +1,5 @@ +import { defineExtension } from '@proj-airi/plugin-sdk' + function nowIso() { return new Date().toISOString() } @@ -5,18 +7,12 @@ function nowIso() { /** * Example plugin for verifying plugin-host lifecycle in devtools. * - * This module intentionally avoids external package imports so it can run - * from the userData plugins folder without additional dependency setup. + * This module uses the public extension authoring API so it matches the + * package shape expected by the current host loader. */ -export async function init(_context) { - console.info('[devtools-sample-plugin] init', { at: nowIso() }) -} - -export async function setupModules({ apis }) { - const providers = await apis.providers.listProviders() - console.info('[devtools-sample-plugin] setupModules', { - at: nowIso(), - providerCount: providers.length, - providerNames: providers.map(provider => provider.name), - }) -} +export default defineExtension({ + id: 'devtools-sample-plugin', + setup() { + console.info('[devtools-sample-plugin] setup', { at: nowIso() }) + }, +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/plugin.airi.json b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json similarity index 89% rename from apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/plugin.airi.json rename to apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json index 51514d400..67f9a2ef7 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/plugin.airi.json +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/extension.airi.json @@ -1,7 +1,7 @@ { "apiVersion": "v1", - "kind": "manifest.plugin.airi.moeru.ai", - "name": "devtools-sample-plugin", + "kind": "manifest.extension.airi.moeru.ai", + "id": "devtools-sample-plugin", "permissions": { "apis": [ { diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts index 0691ddeda..e6ffe6525 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts @@ -2,28 +2,30 @@ import type { FSWatcher } from 'node:fs' import type { useLogg } from '@guiiai/logg' -import type { ManifestEntry, PluginConfig } from '../../types' +import type { ExtensionConfig, ManifestEntry } from '../../types' import { watch as watchFile } from 'node:fs' +import { manifestIdOf } from '../../host/registry' + /** - * Declares the host-owned callbacks needed by the plugin auto-reload feature. + * Declares the host-owned callbacks needed by the extension auto-reload feature. * * Use when: - * - Installing the optional auto-reload feature into the Electron plugin host + * - Installing the optional auto-reload feature into the Electron extension host * - Keeping file-watcher ownership outside the core host bootstrap * * Expects: - * - `reload` unloads, refreshes, and loads the named plugin - * - `resolveWatchPaths` returns stable absolute file paths for the plugin + * - `reload` unloads, refreshes, and loads the named extension + * - `resolveWatchPaths` returns stable absolute file paths for the extension * - `getConfig`, `listEntries`, and `isLoaded` always reflect current host state * * Returns: * - N/A */ -export interface PluginAutoReloadFeatureOptions { +export interface ExtensionAutoReloadFeatureOptions { log: ReturnType - getConfig: () => PluginConfig + getConfig: () => ExtensionConfig listEntries: () => ManifestEntry[] isLoaded: (name: string) => boolean resolveWatchPaths: (name: string) => string[] @@ -31,21 +33,21 @@ export interface PluginAutoReloadFeatureOptions { } /** - * Manages optional plugin auto-reload watchers and debounce timers. + * Manages optional extension auto-reload watchers and debounce timers. * * Use when: - * - The Electron plugin host wants manifest and entrypoint file watching as an installable feature + * - The Electron extension host wants manifest and entrypoint file watching as an installable feature * - Host bootstrap should delegate watcher lifecycle and reload scheduling out of `host/index.ts` * * Expects: * - Call `sync()` after registry/config/load-state changes - * - Call `clearPlugin(name)` before unloading or disabling a plugin + * - Call `clearExtension(name)` before unloading or disabling a plugin * - Call `dispose()` during host shutdown * * Returns: * - The installed auto-reload feature controller */ -export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOptions) { +export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFeatureOptions) { const autoReloadInFlight = new Set() const autoReloadTimers = new Map>() const autoReloadWatchers = new Map() @@ -73,7 +75,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp autoReloadWatchers.delete(name) } - const reloadPluginByName = async (name: string, changedPath: string) => { + const reloadExtensionById = async (name: string, changedPath: string) => { if (autoReloadInFlight.has(name)) { return } @@ -81,10 +83,10 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp autoReloadInFlight.add(name) try { await options.reload(name, changedPath) - options.log.log('plugin auto-reloaded after file change', { plugin: name, path: changedPath }) + options.log.log('extension auto-reloaded after file change', { extension: name, path: changedPath }) } catch (error) { - options.log.withError(error).withFields({ plugin: name, path: changedPath }).error('plugin auto-reload failed') + options.log.withError(error).withFields({ extension: name, path: changedPath }).error('extension auto-reload failed') } finally { autoReloadInFlight.delete(name) @@ -95,7 +97,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp clearTimer(name) autoReloadTimers.set(name, setTimeout(() => { autoReloadTimers.delete(name) - void reloadPluginByName(name, changedPath) + void reloadExtensionById(name, changedPath) }, 180)) } @@ -103,7 +105,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp sync() { const enabledNames = new Set(options.getConfig().autoReload) const desiredNames = new Set(options.listEntries() - .map(entry => entry.manifest.name) + .map(entry => manifestIdOf(entry.manifest)) .filter(name => enabledNames.has(name) && options.isLoaded(name))) for (const name of autoReloadWatchers.keys()) { @@ -128,12 +130,12 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp try { const watcher = watchFile(watchPath, { persistent: false }, () => scheduleReload(name, watchPath)) watcher.on('error', (error) => { - options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('plugin auto-reload watcher error') + options.log.withError(error).withFields({ extension: name, path: watchPath }).warn('extension auto-reload watcher error') }) watchers.push(watcher) } catch (error) { - options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('failed to watch plugin file for auto-reload') + options.log.withError(error).withFields({ extension: name, path: watchPath }).warn('failed to watch extension file for auto-reload') } } @@ -142,7 +144,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp } } }, - clearPlugin(name: string) { + clearExtension(name: string) { clearTimer(name) closeWatchers(name) }, diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts index 349c1a6e6..062eaab20 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts @@ -9,7 +9,7 @@ import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/ * Describes one plugin asset session creation request. * * Use when: - * - A plugin-owned asset URL must be mounted behind the local loopback server with cookie auth + * - A extension-owned asset URL must be mounted behind the local loopback server with cookie auth * - Snapshot builders need a transport-agnostic way to authorize one plugin asset route before iframe load * * Expects: @@ -25,7 +25,7 @@ export interface PluginAssetSessionInput { pluginId: string /** Plugin version expected by the server-side session validator. */ version: string - /** Parent plugin session id used for owner-scoped revocation. */ + /** Parent extension session id used for owner-scoped revocation. */ ownerSessionId: string /** Asset path to mount in the returned renderer-facing URL. */ routeAssetPath: string @@ -125,7 +125,7 @@ export interface PluginAssetSession { } /** - * Defines the plugin-owned asset hosting service used by the plugin host. + * Defines the extension-owned asset hosting service used by the extension host. * * Use when: * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape @@ -172,11 +172,11 @@ async function removeCookies(cookieAdapter: PluginAssetCookieAdapter, baseUrl: s * Creates the plugin asset host service backed by the extension static asset server. * * Use when: - * - The plugin host needs to expose mounted asset URLs to renderer snapshots + * - The extension host needs to expose mounted asset URLs to renderer snapshots * - Asset session lifecycle should stay inside the plugin domain instead of the HTTP server layer * * Expects: - * - `getManifestEntryByName` returns the latest plugin root/version map + * - `getManifestEntryByName` returns the latest extension root/version map * - `cookieAdapter` writes and removes cookies in the Electron host session used by plugin iframes * * Returns: diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts index 12e02ab4a..7ab7fab4b 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts @@ -1,10 +1,10 @@ -import type { PluginConfig } from '../types' +import type { ExtensionConfig } from '../types' import { array, object, record, string } from 'valibot' import { createConfig } from '../../../../libs/electron/persistence' -const pluginConfigSchema = object({ +const extensionConfigSchema = object({ enabled: array(string()), autoReload: array(string()), known: record(string(), object({ @@ -12,7 +12,7 @@ const pluginConfigSchema = object({ })), }) -function createDefaultPluginConfig(): PluginConfig { +function createDefaultExtensionConfig(): ExtensionConfig { return { enabled: [], autoReload: [], @@ -21,27 +21,27 @@ function createDefaultPluginConfig(): PluginConfig { } /** - * Persists plugin host enablement and discovery metadata. + * Persists extension host enablement and discovery metadata. * * Use when: - * - Bootstrapping the Electron plugin host - * - Reading or updating `plugins-v1.json` state + * - Bootstrapping the Electron extension host + * - Reading or updating `extensions-v1.json` state * * Expects: * - `setup()` runs before `get()` or `update()` - * - Consumers write complete `PluginConfig` snapshots + * - Consumers write complete `ExtensionConfig` snapshots * * Returns: - * - Accessors around the persisted plugin config document + * - Accessors around the persisted extension config document */ -export interface PluginHostConfigStore { +export interface ExtensionHostConfigStore { setup: () => void - get: () => PluginConfig - update: (config: PluginConfig) => void + get: () => ExtensionConfig + update: (config: ExtensionConfig) => void } /** - * Creates the persisted config store used by the plugin host bootstrap. + * Creates the persisted config store used by the extension host bootstrap. * * Use when: * - Host bootstrap modules need config persistence without inlining schema setup @@ -50,23 +50,23 @@ export interface PluginHostConfigStore { * - Electron `app.getPath('userData')` is available through the persistence layer * * Returns: - * - A small config store that always falls back to the default plugin config + * - A small config store that always falls back to the default extension config */ -export function createPluginHostConfigStore(): PluginHostConfigStore { - const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, { - default: createDefaultPluginConfig(), +export function createExtensionHostConfigStore(): ExtensionHostConfigStore { + const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, { + default: createDefaultExtensionConfig(), autoHeal: true, }) return { setup() { - pluginConfig.setup() + extensionConfig.setup() }, get() { - return pluginConfig.get() ?? createDefaultPluginConfig() + return extensionConfig.get() ?? createDefaultExtensionConfig() }, update(config) { - pluginConfig.update(config) + extensionConfig.update(config) }, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts index abb3001e8..87225c692 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts @@ -1,35 +1,35 @@ -import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' import type { PluginHostDebugSnapshot, PluginHostModuleSummary, } from '../../../../../shared/eventa/plugin/host' import type { PluginAssetSnapshotService } from '../features/static-assets' -import type { ManifestEntry, PluginConfig } from '../types' +import type { ExtensionConfig, ManifestEntry } from '../types' import { rewriteWidgetModuleAssetUrl } from '../kits/widget' import { buildPluginRegistrySnapshot } from './registry' /** - * Builds the debug snapshot exposed by the Electron plugin host inspector. + * Builds the debug snapshot exposed by the Electron extension host inspector. * * Use when: * - Renderer devtools need sessions, kits, modules, and capability state * - Widget iframe asset URLs must be rewritten to mounted plugin asset URLs * * Expects: - * - `host` is the initialized plugin host instance - * - `manifestEntryByName` contains entries for any plugin-owned modules being inspected + * - `host` is the initialized extension host instance + * - `manifestEntryByName` contains entries for any extension-owned modules being inspected * - `pluginAssetService` owns plugin asset URL/session lifecycle when mounted asset URLs are needed * * Returns: * - A full debug snapshot with registry, sessions, kits, modules, and capabilities */ export function buildPluginHostDebugSnapshot(options: { - host: PluginHost - pluginsRoot: string + host: ExtensionHost + extensionsRoot: string entries: ManifestEntry[] - config: PluginConfig + config: ExtensionConfig loaded: Set manifestEntryByName: Map pluginAssetService?: PluginAssetSnapshotService @@ -66,17 +66,17 @@ export function buildPluginHostDebugSnapshot(options: { return modules.then(resolvedModules => ({ registry: buildPluginRegistrySnapshot({ - pluginsRoot: options.pluginsRoot, + extensionsRoot: options.extensionsRoot, entries: options.entries, config: options.config, loaded: options.loaded, }), sessions: options.host.listSessions().map(session => ({ id: session.id, - manifestName: session.manifest.name, + manifestName: session.manifest.id, phase: session.phase, - runtime: session.runtime, - moduleId: session.identity.id, + runtime: session.runtime ?? 'electron', + moduleId: session.extension.id, })), kits: options.host.listKits(), modules: resolvedModules as PluginHostDebugSnapshot['modules'], diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts index 02c10d652..dbb908bf1 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts @@ -1,3 +1,5 @@ +import type { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools' + import type { PluginHostDebugSnapshot, PluginRegistrySnapshot, @@ -7,32 +9,30 @@ import type { PluginAssetSession, PluginAssetSnapshotService, } from '../features/static-assets' -import type { - PluginHostService, - SetupPluginHostOptions, -} from '../types' +import type { ExtensionHostService, SetupExtensionHostOptions } from '../types' import { dirname, join } from 'node:path' import { useLogg } from '@guiiai/logg' -import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' import { app, session as electronSession } from 'electron' -import { createPluginAutoReloadFeature } from '../features/auto-reload' +import { createExtensionAutoReloadFeature } from '../features/auto-reload' import { createPluginAssetService } from '../features/static-assets' -import { createBuiltInPluginKitRuntime } from '../kits' -import { createPluginHostConfigStore } from './config' +import { createBuiltInExtensionKitRuntime } from '../kits' +import { createExtensionHostConfigStore } from './config' import { buildPluginHostDebugSnapshot } from './debug' import { buildPluginRegistrySnapshot, + createExtensionHostRegistry, createManifestForLoad, - createPluginHostRegistry, + manifestIdOf, resolvePluginRuntimeEntrypointPath, } from './registry' const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000 -function createElectronPluginAssetCookieAdapter() { +function createElectronExtensionAssetCookieAdapter() { return { async setCookie(cookie: PluginAssetCookie) { await electronSession.defaultSession.cookies.set({ @@ -53,7 +53,7 @@ function createElectronPluginAssetCookieAdapter() { } /** - * Internal plugin host bootstrap service used by the public `setupPluginHost(...)` facade. + * Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade. * * Use when: * - `plugins/index.ts` needs a smaller orchestration layer with the same caller-facing API @@ -64,11 +64,14 @@ function createElectronPluginAssetCookieAdapter() { * - `widgetsManager` is ready before startup begins * * Returns: - * - The plain `PluginHostService` fields plus internal helpers for list/load/unload/inspect/dispose + * - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose */ -export interface PluginHostHostService extends PluginHostService { +export interface ExtensionHostServiceInternal extends ExtensionHostService { + /** Tamagotchi-owned extension tool registry used by IPC tool bridges. */ + tools: TamagotchiToolRegistry + /** - * Lists the current plugin registry snapshot. + * Lists the current extension registry snapshot. * * Use when: * - IPC callers need the latest discovered plugin entries and enablement state @@ -78,7 +81,7 @@ export interface PluginHostHostService extends PluginHostService { * - Manifest discovery can be refreshed before the snapshot is built * * Returns: - * - The latest plugin registry snapshot for renderer consumption + * - The latest extension registry snapshot for renderer consumption */ list: () => Promise @@ -94,7 +97,7 @@ export interface PluginHostHostService extends PluginHostService { * - `payload.path` is only needed when the manifest is not currently discoverable * * Returns: - * - The updated plugin registry snapshot after persistence + * - The updated extension registry snapshot after persistence */ setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise @@ -109,7 +112,7 @@ export interface PluginHostHostService extends PluginHostService { * - `payload.name` matches one plugin entry in config or discovery state * * Returns: - * - The updated plugin registry snapshot after persistence + * - The updated extension registry snapshot after persistence */ setAutoReload: (payload: { name: string, enabled: boolean }) => Promise @@ -124,7 +127,7 @@ export interface PluginHostHostService extends PluginHostService { * - Discovery state is current before load begins * * Returns: - * - The plugin registry snapshot after load attempts finish + * - The extension registry snapshot after load attempts finish */ loadEnabled: () => Promise @@ -139,7 +142,7 @@ export interface PluginHostHostService extends PluginHostService { * - `name` resolves to a manifest entry in the current registry * * Returns: - * - The plugin registry snapshot after the load completes + * - The extension registry snapshot after the load completes */ load: (name: string) => Promise @@ -154,12 +157,12 @@ export interface PluginHostHostService extends PluginHostService { * - `name` identifies a plugin that may or may not currently be loaded * * Returns: - * - The plugin registry snapshot after unload bookkeeping completes + * - The extension registry snapshot after unload bookkeeping completes */ unload: (name: string) => Promise /** - * Builds the full plugin host debug snapshot. + * Builds the full extension host debug snapshot. * * Use when: * - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs @@ -192,7 +195,7 @@ export interface PluginHostHostService extends PluginHostService { * Disposes optional host features and asset hosting resources. * * Use when: - * - Electron shutdown needs to stop plugin-owned background work + * - Electron shutdown needs to stop extension-owned background work * - Tests need to release watchers and local asset servers deterministically * * Expects: @@ -205,50 +208,50 @@ export interface PluginHostHostService extends PluginHostService { } /** - * Builds the extracted Electron plugin host bootstrap used by the public facade. + * Builds the extracted Electron extension host bootstrap used by the public facade. * * Use when: - * - The public plugin service wants one internal bootstrap entrypoint + * - The public extension service wants one internal bootstrap entrypoint * - Tests need direct access to the internal host bootstrap helper * * Expects: * - Electron `app.getPath('userData')` is available - * - Plugin manifests live under `/plugins/v1` + * - Extension manifests live under `/extensions/v1` * * Returns: - * - The internal bootstrap service that powers the public plugin-host IPC facade + * - The internal bootstrap service that powers the public extension-host IPC facade */ -export async function setupPluginHostHostService( - options: SetupPluginHostOptions, -): Promise { - const log = useLogg('main/plugin-host').useGlobalConfig() - const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1') +export async function setupExtensionHostServiceInternal( + options: SetupExtensionHostOptions, +): Promise { + const log = useLogg('main/extension-host').useGlobalConfig() + const extensionsRoot = join(app.getPath('userData'), 'extensions', 'v1') // Config - const pluginConfig = createPluginHostConfigStore() - pluginConfig.setup() + const extensionConfig = createExtensionHostConfigStore() + extensionConfig.setup() // Kit API, Host - const builtInKitRuntime = createBuiltInPluginKitRuntime(options) - const host = new PluginHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions }) + const builtInKitRuntime = createBuiltInExtensionKitRuntime(options) + const host = new ExtensionHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions }) builtInKitRuntime.attachHost(host) // reverse dependency injection - log.withFields({ pluginsRoot }).log('loading plugin manifests') + log.withFields({ extensionsRoot }).log('loading extension manifests') // Once kit injected the host, then apply kits builtInKitRuntime.registerHostKits(host) - // plugin registry - const pluginRegistry = createPluginHostRegistry({ pluginsRoot, log }) + // extension registry + const extensionRegistry = createExtensionHostRegistry({ extensionsRoot, log }) - await pluginRegistry.refresh() - log.withFields({ count: pluginRegistry.listEntries().length }).log('plugin manifests loaded') - for (const entry of pluginRegistry.listEntries()) { - log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found') + await extensionRegistry.refresh() + log.withFields({ count: extensionRegistry.listEntries().length }).log('extension manifests loaded') + for (const entry of extensionRegistry.listEntries()) { + log.withFields({ name: manifestIdOf(entry.manifest), path: entry.path }).log('extension manifest found') } - // Plugin feature: Static Assets serving + // Extension feature: Static Assets serving const pluginAssetService = createPluginAssetService({ - getManifestEntryByName: () => pluginRegistry.getManifestEntryByName(), - cookieAdapter: createElectronPluginAssetCookieAdapter(), + getManifestEntryByName: () => extensionRegistry.getManifestEntryByName(), + cookieAdapter: createElectronExtensionAssetCookieAdapter(), }) await pluginAssetService.start() @@ -256,7 +259,7 @@ export async function setupPluginHostHostService( const loadedSessionIds = new Map() const moduleAssetSessionCache = new Map() - const clearModuleAssetSessionCacheByPluginId = (pluginId: string) => { + const clearModuleAssetSessionCacheByExtensionId = (pluginId: string) => { for (const key of moduleAssetSessionCache.keys()) { if (key.startsWith(`${pluginId}:`)) { moduleAssetSessionCache.delete(key) @@ -274,15 +277,15 @@ export async function setupPluginHostHostService( } const refreshManifests = async () => { - await pluginRegistry.refresh() + await extensionRegistry.refresh() } - const getConfig = () => pluginConfig.get() + const getConfig = () => extensionConfig.get() const listSnapshot = (): PluginRegistrySnapshot => { return buildPluginRegistrySnapshot({ - pluginsRoot, - entries: pluginRegistry.listEntries(), + extensionsRoot, + entries: extensionRegistry.listEntries(), config: getConfig(), loaded, }) @@ -330,16 +333,16 @@ export async function setupPluginHostHostService( const inspectSnapshot = async (): Promise => { return await buildPluginHostDebugSnapshot({ host, - pluginsRoot, - entries: pluginRegistry.listEntries(), + extensionsRoot, + entries: extensionRegistry.listEntries(), config: getConfig(), loaded, - manifestEntryByName: pluginRegistry.getManifestEntryByName(), + manifestEntryByName: extensionRegistry.getManifestEntryByName(), pluginAssetService: pluginAssetSnapshotService, }) } - const loadPluginByName = async ( + const loadExtensionById = async ( name: string, loadOptions: { cacheBustKey?: string } = {}, ) => { @@ -347,37 +350,37 @@ export async function setupPluginHostHostService( return } - const entry = pluginRegistry.findManifestEntry(name) + const entry = extensionRegistry.findManifestEntry(name) if (!entry) { - throw new Error(`Plugin manifest not found: ${name}`) + throw new Error(`Extension manifest not found: ${name}`) } const manifestForLoad = createManifestForLoad(entry, loadOptions) const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) }) loaded.add(name) loadedSessionIds.set(name, session.id) - log.log('plugin loaded', { plugin: name, sessionId: session.id }) + log.log('extension loaded', { extension: name, sessionId: session.id }) } - const stopLoadedPluginByName = async (name: string) => { + const stopLoadedExtensionById = async (name: string) => { const sessionId = loadedSessionIds.get(name) if (!sessionId) { loaded.delete(name) return } - host.stop(sessionId) + await host.stop(sessionId) loadedSessionIds.delete(name) loaded.delete(name) clearModuleAssetSessionCacheByOwnerSessionId(sessionId) await pluginAssetService.revokeByOwnerSessionId(sessionId) - log.log('plugin unloaded', { plugin: name, sessionId }) + log.log('extension unloaded', { extension: name, sessionId }) } const resolveAutoReloadWatchPaths = (name: string) => { - const entry = pluginRegistry.findManifestEntry(name) + const entry = extensionRegistry.findManifestEntry(name) if (!entry) { return [] } @@ -386,29 +389,29 @@ export async function setupPluginHostHostService( return [...new Set([entry.path, entrypointPath].filter((path): path is string => Boolean(path)))] } - // Plugin feature: Auto-reload for plugins - const autoReloadFeature = createPluginAutoReloadFeature({ + // Extension feature: Auto-reload for plugins + const autoReloadFeature = createExtensionAutoReloadFeature({ log, getConfig, - listEntries: () => pluginRegistry.listEntries(), + listEntries: () => extensionRegistry.listEntries(), isLoaded: name => loaded.has(name), resolveWatchPaths: resolveAutoReloadWatchPaths, reload: async (name) => { - await stopLoadedPluginByName(name) + await stopLoadedExtensionById(name) await refreshManifests() - await loadPluginByName(name, { cacheBustKey: `auto-reload-${Date.now()}` }) + await loadExtensionById(name, { cacheBustKey: `auto-reload-${Date.now()}` }) }, }) - const unloadPluginByName = async (name: string) => { - autoReloadFeature.clearPlugin(name) - await stopLoadedPluginByName(name) + const unloadExtensionById = async (name: string) => { + autoReloadFeature.clearExtension(name) + await stopLoadedExtensionById(name) } - const loadEnabledPlugins = async () => { + const loadEnabledExtensions = async () => { const config = getConfig() - for (const entry of pluginRegistry.listEntries()) { - const name = entry.manifest.name + for (const entry of extensionRegistry.listEntries()) { + const name = manifestIdOf(entry.manifest) if (!config.enabled.includes(name)) { continue } @@ -417,10 +420,10 @@ export async function setupPluginHostHostService( } try { - await loadPluginByName(name) + await loadExtensionById(name) } catch (error) { - log.withError(error).withFields({ plugin: name }).error('plugin failed to start') + log.withError(error).withFields({ extension: name }).error('extension failed to start') } } @@ -428,12 +431,13 @@ export async function setupPluginHostHostService( } await refreshManifests() - await loadEnabledPlugins() + await loadEnabledExtensions() autoReloadFeature.sync() return { host, - manifests: pluginRegistry.listManifests(), + tools: builtInKitRuntime.tools, + manifests: extensionRegistry.listManifests(), async list() { await refreshManifests() autoReloadFeature.sync() @@ -449,13 +453,13 @@ export async function setupPluginHostHostService( } else { enabled.delete(payload.name) - clearModuleAssetSessionCacheByPluginId(payload.name) + clearModuleAssetSessionCacheByExtensionId(payload.name) await pluginAssetService.revokeByPluginId(payload.name) } - const entry = pluginRegistry.findManifestEntry(payload.name) + const entry = extensionRegistry.findManifestEntry(payload.name) const manifestPath = entry?.path ?? payload.path ?? '' - pluginConfig.update({ + extensionConfig.update({ enabled: [...enabled], autoReload: config.autoReload, known: { @@ -479,7 +483,7 @@ export async function setupPluginHostHostService( autoReload.delete(payload.name) } - pluginConfig.update({ + extensionConfig.update({ ...config, autoReload: [...autoReload], }) @@ -489,18 +493,18 @@ export async function setupPluginHostHostService( }, async loadEnabled() { await refreshManifests() - await loadEnabledPlugins() + await loadEnabledExtensions() autoReloadFeature.sync() return listSnapshot() }, async load(name) { await refreshManifests() - await loadPluginByName(name) + await loadExtensionById(name) autoReloadFeature.sync() return listSnapshot() }, async unload(name) { - await unloadPluginByName(name) + await unloadExtensionById(name) autoReloadFeature.sync() return listSnapshot() }, @@ -514,6 +518,7 @@ export async function setupPluginHostHostService( }, async dispose() { autoReloadFeature.dispose() + builtInKitRuntime.dispose() moduleAssetSessionCache.clear() await pluginAssetService.revokeAll() diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts index 26a714a44..2f2a90894 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts @@ -1,24 +1,28 @@ import type { Dirent } from 'node:fs' import type { useLogg } from '@guiiai/logg' -import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' +import type { ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' import type { PluginManifestSummary, PluginRegistrySnapshot, } from '../../../../../shared/eventa/plugin/host' -import type { ManifestEntry, PluginConfig } from '../types' +import type { ExtensionConfig, ManifestEntry } from '../types' import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { manifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host' +import { extensionManifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host' import { safeParse } from 'valibot' -export const pluginManifestFileName = 'plugin.airi.json' +export const extensionManifestFileName = 'extension.airi.json' -function isManifestV1(value: unknown): value is ManifestV1 { - return safeParse(manifestV1Schema, value).success +function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 { + return safeParse(extensionManifestV1Schema, value).success +} + +export function manifestIdOf(manifest: ExtensionManifestV1) { + return manifest.id } async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { @@ -41,16 +45,16 @@ async function realPathOf(entry: Dirent, options?: { cwd?: string }): Pr } /** - * Loads plugin manifests from plugin subdirectories under the configured root. + * Loads extension manifests from plugin subdirectories under the configured root. * * Use when: - * - Refreshing the plugin registry state from disk + * - Refreshing the extension registry state from disk * - Resolving symlink-backed plugin directories before manifest parsing * * Expects: * - Root directory may not exist yet * - Each plugin is nested under its own child directory - * - Each plugin directory may include `plugin.airi.json` and optional `package.json` + * - Each extension directory may include `extension.airi.json` and optional `package.json` * * Returns: * - Array of validated manifest entries with resolved paths and version metadata @@ -69,7 +73,7 @@ export async function loadManifestsFrom( 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') + log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping') continue } if (!resolved) { @@ -82,11 +86,11 @@ export async function loadManifestsFrom( } } - let pluginDir = join(dir, entry.name) + let extensionDir = join(dir, entry.name) if (entry.isSymbolicLink()) { const { path, resolved } = await realPathOf(entry, { cwd: dir }) if (resolved) { - pluginDir = path + extensionDir = path } else { log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') @@ -94,15 +98,15 @@ export async function loadManifestsFrom( } } - const pluginEntries = await readdir(pluginDir, { withFileTypes: true }) - const manifestEntry = pluginEntries.find(candidate => candidate.name === pluginManifestFileName) + const extensionEntries = await readdir(extensionDir, { withFileTypes: true }) + const manifestEntry = extensionEntries.find(candidate => candidate.name === extensionManifestFileName) if (!manifestEntry) { continue } - const manifestPath = join(pluginDir, pluginManifestFileName) + const manifestPath = join(extensionDir, extensionManifestFileName) if (manifestEntry.isFile()) { - manifestPaths.push({ path: manifestPath, rootDir: pluginDir }) + manifestPaths.push({ path: manifestPath, rootDir: extensionDir }) continue } if (!manifestEntry.isSymbolicLink()) { @@ -115,7 +119,7 @@ export async function loadManifestsFrom( if (!stats.isFile()) { continue } - manifestPaths.push({ path: manifestPath, rootDir: pluginDir }) + manifestPaths.push({ path: manifestPath, rootDir: extensionDir }) } catch (error) { log.withError(error).withFields({ name: manifestEntry.name }).warn('failed to resolve symlink, skipping') @@ -126,8 +130,8 @@ export async function loadManifestsFrom( try { const raw = await readFile(manifestPath.path, 'utf-8') const parsed = JSON.parse(raw) as unknown - if (!isManifestV1(parsed)) { - log.warn('invalid plugin manifest schema', { path: manifestPath.path }) + if (!isExtensionManifestV1(parsed)) { + log.warn('invalid extension manifest schema', { path: manifestPath.path }) continue } @@ -140,7 +144,7 @@ export async function loadManifestsFrom( } } catch { - // Ignore package.json read failures; plugin manifests without package metadata + // Ignore package.json read failures; extension manifests without package metadata // still load with a deterministic fallback version. } @@ -152,7 +156,7 @@ export async function loadManifestsFrom( }) } catch (error) { - log.withError(error).withFields({ path: manifestPath.path }).error('failed to read plugin manifest') + log.withError(error).withFields({ path: manifestPath.path }).error('failed to read extension manifest') } } @@ -167,7 +171,7 @@ export async function loadManifestsFrom( * * Expects: * - `entry` corresponds to a currently discovered manifest - * - `config` is the latest persisted plugin config + * - `config` is the latest persisted extension config * - `loaded` tracks currently running plugin names * * Returns: @@ -175,10 +179,10 @@ export async function loadManifestsFrom( */ export function createPluginSummary( entry: ManifestEntry, - config: PluginConfig, + config: ExtensionConfig, loaded: Set, ): PluginManifestSummary { - const name = entry.manifest.name + const name = manifestIdOf(entry.manifest) return { name, entrypoints: entry.manifest.entrypoints, @@ -191,7 +195,7 @@ export function createPluginSummary( } /** - * Builds the renderer-facing plugin registry snapshot. + * Builds the renderer-facing extension registry snapshot. * * Use when: * - IPC clients request the plugin list @@ -204,13 +208,13 @@ export function createPluginSummary( * - A stable registry snapshot for renderer consumption */ export function buildPluginRegistrySnapshot(options: { - pluginsRoot: string + extensionsRoot: string entries: ManifestEntry[] - config: PluginConfig + config: ExtensionConfig loaded: Set }): PluginRegistrySnapshot { return { - root: options.pluginsRoot, + root: options.extensionsRoot, plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)), } } @@ -260,12 +264,13 @@ function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string { export function createManifestForLoad( entry: ManifestEntry, options: { cacheBustKey?: string }, -): ManifestV1 { +): ExtensionManifestV1 { + const loadManifest = entry.manifest if (!options.cacheBustKey) { - return entry.manifest + return loadManifest } - const manifest = structuredClone(entry.manifest) + const manifest = structuredClone(loadManifest) if (manifest.entrypoints.electron) { manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) } @@ -276,30 +281,30 @@ export function createManifestForLoad( } /** - * Tracks the manifest registry state used by the Electron plugin host. + * Tracks the manifest registry state used by the Electron extension host. * * Use when: - * - Refreshing plugin manifests from disk + * - Refreshing extension manifests from disk * - Looking up manifests by plugin name during load or inspect operations * * Expects: * - `refresh()` is called before consumers read entries or manifests - * - `pluginsRoot` points at the plugin manifest root under user data + * - `extensionsRoot` points at the extension manifest root under user data * * Returns: * - Read access to the current manifest entries, manifest list, and lookup map */ -export interface PluginHostRegistry { +export interface ExtensionHostRegistry { getRoot: () => string refresh: () => Promise listEntries: () => ManifestEntry[] - listManifests: () => ManifestV1[] + listManifests: () => ExtensionManifestV1[] findManifestEntry: (name: string) => ManifestEntry | undefined getManifestEntryByName: () => Map } /** - * Creates the manifest registry store used by the plugin host bootstrap. + * Creates the manifest registry store used by the extension host bootstrap. * * Use when: * - Host bootstrap needs in-memory manifest lookup and refresh operations @@ -310,24 +315,25 @@ export interface PluginHostRegistry { * Returns: * - A registry wrapper around the current manifest entry array and lookup map */ -export function createPluginHostRegistry(options: { - pluginsRoot: string +export function createExtensionHostRegistry(options: { + extensionsRoot: string log: ReturnType -}): PluginHostRegistry { +}): ExtensionHostRegistry { let entries: ManifestEntry[] = [] - let manifests: ManifestV1[] = [] + let manifests: ExtensionManifestV1[] = [] let manifestEntryByName = new Map() return { getRoot() { - return options.pluginsRoot + return options.extensionsRoot }, async refresh() { - entries = await loadManifestsFrom(options.pluginsRoot, options.log) + entries = await loadManifestsFrom(options.extensionsRoot, options.log) manifestEntryByName = new Map() for (const entry of entries) { - if (!manifestEntryByName.has(entry.manifest.name)) { - manifestEntryByName.set(entry.manifest.name, entry) + const id = manifestIdOf(entry.manifest) + if (!manifestEntryByName.has(id)) { + manifestEntryByName.set(id, entry) } } manifests = entries.map(entry => entry.manifest) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 414396112..26132c89e 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -1,20 +1,22 @@ import type { createContext } from '@moeru/eventa' import type { BindingRecord, + ExtensionManifestV1, HostDataRecord, - ManifestV1, ModulePermissionDeclaration, } from '@proj-airi/plugin-sdk/plugin-host' import type { WidgetsAddPayload, WidgetSnapshot, WidgetsUpdatePayload } from '../../../../shared/eventa' -import type { PluginHostService } from './types' +import type { ExtensionHostService } from './types' -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { useLogg } from '@guiiai/logg' import { defineInvoke } from '@moeru/eventa' -import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { @@ -31,21 +33,13 @@ import { electronPluginSetEnabled, electronPluginUnload, } from '../../../../shared/eventa/plugin/host' -import { - electronPluginInvokeTool, - electronPluginListAgentTools, - electronPluginListXsaiTools, -} from '../../../../shared/eventa/plugin/tools' -import { setupPluginHostHostService } from './host' -import { setupPluginHost as setupPluginHostService } from './index' +import { setupExtensionHostServiceInternal } from './host' +import { loadManifestsFrom } from './host/registry' +import { setupExtensionHost as setupExtensionHostService } from './index' import { gameletPluginKitDescriptor, - pluginGameletApiCloseEventName, - pluginGameletApiConfigureEventName, - pluginGameletApiIsOpenEventName, - pluginGameletApiOpenEventName, - pluginGameletApiRequestEventName, } from './kits/gamelet' +import { createGameletOrchestrationRuntime } from './kits/gamelet/orchestration' import { widgetPluginKitDescriptor } from './kits/widget' const appMock = vi.hoisted(() => ({ @@ -114,25 +108,20 @@ const samplePluginRoot = resolve( 'examples', 'devtools-sample-plugin', ) -const chessLikePluginRoot = resolve( - repoRoot, - 'plugins', - 'airi-plugin-game-chess', -) -const pluginManifestFileName = 'plugin.airi.json' +const extensionManifestFileName = 'extension.airi.json' async function writeManifest(params: { dir: string, name: string, entrypoint: string }) { const manifest = { apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: params.name, + kind: 'manifest.extension.airi.moeru.ai' as const, + id: params.name, permissions: {}, entrypoints: { electron: params.entrypoint, }, } - const path = join(params.dir, pluginManifestFileName) + const path = join(params.dir, extensionManifestFileName) await writeFile(path, JSON.stringify(manifest, null, 2)) return path } @@ -164,6 +153,45 @@ async function writeEntrypoint(params: { dir: string, name: string, contents: st return destination } +async function linkWorkspacePackageForPlugin(pluginDir: string, packageName: '@proj-airi/plugin-sdk' | '@proj-airi/plugin-sdk-tamagotchi') { + const packageDirName = packageName.replace('@proj-airi/', '') + const packageDir = join(pluginDir, 'node_modules', '@proj-airi', packageDirName) + await mkdir(packageDir, { recursive: true }) + await symlink(resolve(repoRoot, 'packages', packageDirName, 'src'), join(packageDir, 'src'), 'dir') + + const exports = packageName === '@proj-airi/plugin-sdk' + ? { + '.': './src/index.ts', + './plugin-host': './src/plugin-host/index.ts', + } + : { + '.': './src/index.ts', + './widgets': './src/widgets/index.ts', + './gamelet': './src/gamelet/index.ts', + './kits/gamelet': './src/kits/gamelet/index.ts', + './kits/tool': './src/kits/tool/index.ts', + './tools': './src/tools/index.ts', + } + + await writeFile(join(packageDir, 'package.json'), JSON.stringify({ + name: packageName, + type: 'module', + exports, + })) +} + +function createEmptyExtensionEntrypoint(id: string) { + const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href + return [ + `import { defineExtension } from ${JSON.stringify(pluginSdkUrl)}`, + '', + 'export default defineExtension({', + ` id: ${JSON.stringify(id)},`, + ' setup() {},', + '})', + ].join('\n') +} + async function removeDirWithRetry(path: string, options: { attempts?: number, waitMs?: number } = {}) { const attempts = Math.max(1, options.attempts ?? 5) const waitMs = Math.max(1, options.waitMs ?? 20) @@ -182,7 +210,7 @@ async function removeDirWithRetry(path: string, options: { attempts?: number, wa } } -function createDynamicModuleManifest(entrypoint: string): ManifestV1 { +function createDynamicModuleManifest(entrypoint: string, id = 'test-dynamic-module'): ExtensionManifestV1 { const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' const permissions: ModulePermissionDeclaration = { apis: [ @@ -206,8 +234,8 @@ function createDynamicModuleManifest(entrypoint: string): ManifestV1 { return { apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-dynamic-module', + kind: 'manifest.extension.airi.moeru.ai' as const, + id, permissions, entrypoints: { electron: entrypoint, @@ -215,25 +243,17 @@ function createDynamicModuleManifest(entrypoint: string): ManifestV1 { } } -function createToolEnabledManifest(entrypoint: string): ManifestV1 { - const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' - +function createExtensionGameletKitManifest(entrypoint: string, id = 'test-extension-gamelet-kit'): ExtensionManifestV1 { return { apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin-tools', + kind: 'manifest.extension.airi.moeru.ai' as const, + id, permissions: { apis: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, - { key: providersCapability, actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, + { key: 'kit.gamelet', actions: ['invoke'] }, ], resources: [ - { key: providersCapability, actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:tools', actions: ['write'] }, - ], - capabilities: [ - { key: providersCapability, actions: ['wait'] }, + { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, ], }, entrypoints: { @@ -242,47 +262,8 @@ function createToolEnabledManifest(entrypoint: string): ManifestV1 { } } -function createToolDrivenGameletManifest(entrypoint: string): ManifestV1 { - const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' - - return { - apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin-gamelets', - permissions: { - apis: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, - { key: providersCapability, actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:update', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, - { key: pluginGameletApiOpenEventName, actions: ['invoke'] }, - { key: pluginGameletApiConfigureEventName, actions: ['invoke'] }, - { key: pluginGameletApiRequestEventName, actions: ['invoke'] }, - { key: pluginGameletApiCloseEventName, actions: ['invoke'] }, - { key: pluginGameletApiIsOpenEventName, actions: ['invoke'] }, - ], - resources: [ - { key: providersCapability, actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['read', 'write'] }, - { key: 'proj-airi:plugin-sdk:resources:tools', actions: ['write'] }, - ], - capabilities: [ - { key: providersCapability, actions: ['wait'] }, - ], - }, - entrypoints: { - electron: entrypoint, - }, - } -} - -function createWidgetsManagerDouble() { +function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {}) { + const respondToRequests = options.respondToRequests ?? true const widgetSnapshots = new Map() const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() const publishWidgetEvent = vi.fn((id: string, event: Record) => { @@ -325,13 +306,17 @@ function createWidgetsManagerDouble() { }) const componentProps = payload.componentProps as Record | undefined - const command = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload) - ? (componentProps.payload as Record).command + const request = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload) + ? (componentProps.payload as Record).request : undefined - if (command && typeof command === 'object' && !Array.isArray(command) && typeof (command as Record).requestId === 'string') { - const requestId = (command as Record).requestId + if (respondToRequests && request && typeof request === 'object' && !Array.isArray(request) && typeof (request as Record).requestId === 'string') { + const requestId = (request as Record).requestId queueMicrotask(() => { publishWidgetEvent(payload.id, { + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, payload: { requestId, ready: true, @@ -360,42 +345,32 @@ function createWidgetsManagerDouble() { } } -async function setupPluginHostForTest() { +async function setupExtensionHostForTest() { const widgets = createWidgetsManagerDouble() - const service = await setupPluginHostService({ widgetsManager: widgets.widgetsManager }) + const service = await setupExtensionHostService({ widgetsManager: widgets.widgetsManager }) return { service, ...widgets } } -async function setupPluginHostHostServiceForTest() { +async function setupExtensionHostServiceInternalForTest() { const widgets = createWidgetsManagerDouble() - const service = await setupPluginHostHostService({ widgetsManager: widgets.widgetsManager }) + const service = await setupExtensionHostServiceInternal({ widgetsManager: widgets.widgetsManager }) return { service, ...widgets } } -async function setupPluginHost() { - return (await setupPluginHostForTest()).service +async function setupExtensionHost() { + return (await setupExtensionHostForTest()).service } -function getGameletApis(session: { apis: Record }) { - return session.apis.gamelets as { - open: (id: string, params?: Record) => Promise - configure: (id: string, patch: Record) => Promise - close: (id: string) => Promise - request: (id: string, payload: Record, options?: { timeoutMs?: number }) => Promise> - isOpen: (id: string) => Promise - } -} - -describe('setupPluginHost', () => { +describe('setupExtensionHost', () => { let userDataDir: string let pluginsDir: string - it('types the setup host service as the plain PluginHost surface', () => { - expectTypeOf().toMatchTypeOf() + it('types the setup host service as the plain ExtensionHost surface', () => { + expectTypeOf().toMatchTypeOf() }) - it('types getBinding as an optional lookup on the plain PluginHost surface', () => { - expectTypeOf>().toMatchTypeOf | undefined>() + it('types getBinding as an optional lookup on the plain ExtensionHost surface', () => { + expectTypeOf>().toMatchTypeOf | undefined>() }) it('loads manifests through the internal host bootstrap helper', async () => { @@ -407,17 +382,17 @@ describe('setupPluginHost', () => { entrypointPath: normalEntrypoint, }) - const { service } = await setupPluginHostHostServiceForTest() + const { service } = await setupExtensionHostServiceInternalForTest() - expect(service.host).toBeInstanceOf(PluginHost) + expect(service.host).toBeInstanceOf(ExtensionHost) expect(service.manifests).toEqual([ - expect.objectContaining({ name: 'test-host-helper' }), + expect.objectContaining({ id: 'test-host-helper' }), ]) }) beforeEach(async () => { userDataDir = await mkdtemp(join(tmpdir(), 'airi-plugins-')) - pluginsDir = join(userDataDir, 'plugins', 'v1') + pluginsDir = join(userDataDir, 'extensions', 'v1') await mkdir(pluginsDir, { recursive: true }) appMock.getPath.mockReturnValue(userDataDir) }) @@ -446,7 +421,7 @@ describe('setupPluginHost', () => { entrypointPath: errorEntrypoint, }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) @@ -460,6 +435,42 @@ describe('setupPluginHost', () => { ])) }) + it('discovers extension manifests and ignores legacy extension manifests', async () => { + const extensionDir = join(pluginsDir, 'extension-test') + const legacyDir = join(pluginsDir, 'plugin-legacy') + await mkdir(extensionDir, { recursive: true }) + await mkdir(legacyDir, { recursive: true }) + + await writeFile(join(extensionDir, extensionManifestFileName), JSON.stringify({ + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-test', + permissions: {}, + entrypoints: { + electron: './extension.mjs', + }, + }, null, 2)) + + await writeFile(join(legacyDir, extensionManifestFileName), JSON.stringify({ + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'airi-plugin-legacy', + permissions: {}, + entrypoints: { + electron: './plugin.mjs', + }, + }, null, 2)) + + const entries = await loadManifestsFrom(pluginsDir, useLogg('test/plugin-registry')) + + expect(entries.map(entry => entry.path)).toEqual([ + join(extensionDir, extensionManifestFileName), + ]) + expect(entries.map(entry => 'id' in entry.manifest ? entry.manifest.id : undefined)).toEqual([ + 'airi-extension-test', + ]) + }) + it('ignores root-level manifests and only loads manifests from subdirectories', async () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') @@ -476,7 +487,7 @@ describe('setupPluginHost', () => { entrypoint: rootEntrypointFile, }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) @@ -501,9 +512,7 @@ describe('setupPluginHost', () => { await writeEntrypoint({ dir: successPluginDir, name: 'test-normal-plugin.ts', - contents: [ - 'export async function init() {}', - ].join('\n'), + contents: createEmptyExtensionEntrypoint('test-normal'), }) await writeManifest({ dir: successPluginDir, @@ -517,7 +526,7 @@ describe('setupPluginHost', () => { entrypointPath: errorEntrypoint, }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -543,7 +552,7 @@ describe('setupPluginHost', () => { await writeEntrypoint({ dir: firstPluginDir, name: 'test-normal-plugin.ts', - contents: 'export async function init() {}', + contents: createEmptyExtensionEntrypoint('duplicate-plugin'), }) await writeManifest({ dir: firstPluginDir, @@ -557,7 +566,7 @@ describe('setupPluginHost', () => { entrypointPath: errorEntrypoint, }) - const { service } = await setupPluginHostForTest() + const { service } = await setupExtensionHostForTest() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -568,7 +577,7 @@ describe('setupPluginHost', () => { const duplicateSession = service.host .listSessions() - .find(session => session.manifest.name === 'duplicate-plugin') + .find(session => session.manifest.id === 'duplicate-plugin') expect(duplicateSession).toBeDefined() expect(duplicateSession?.manifest.entrypoints.electron).toBe('./test-normal-plugin.ts') @@ -583,7 +592,7 @@ describe('setupPluginHost', () => { entrypointPath: normalEntrypoint, }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) @@ -608,7 +617,7 @@ describe('setupPluginHost', () => { const entrypointPath = await writeEntrypoint({ dir: pluginDir, name: 'test-auto-reload-reload.ts', - contents: 'export async function init() {}', + contents: createEmptyExtensionEntrypoint('test-auto-reload-reload'), }) await writeManifest({ dir: pluginDir, @@ -616,7 +625,7 @@ describe('setupPluginHost', () => { entrypoint: './test-auto-reload-reload.ts', }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -633,7 +642,17 @@ describe('setupPluginHost', () => { const beforeSession = before.sessions.find(session => session.manifestName === 'test-auto-reload-reload') expect(beforeSession).toBeDefined() - await writeFile(entrypointPath, 'export async function init() { return "changed" }') + const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href + await writeFile(entrypointPath, [ + `import { defineExtension } from ${JSON.stringify(pluginSdkUrl)}`, + '', + 'export default defineExtension({', + ' id: \'test-auto-reload-reload\',', + ' setup() {', + ' return \'changed\'', + ' },', + '})', + ].join('\n')) const deadline = Date.now() + 3000 let afterSessionId = beforeSession?.id @@ -659,9 +678,7 @@ describe('setupPluginHost', () => { const externalEntrypoint = await writeEntrypoint({ dir: externalDir, name: 'test-absolute-plugin.ts', - contents: [ - 'export async function init() {}', - ].join('\n'), + contents: createEmptyExtensionEntrypoint('test-absolute-entrypoint'), }) await writeManifest({ dir: pluginDir, @@ -669,7 +686,7 @@ describe('setupPluginHost', () => { entrypoint: externalEntrypoint, }) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -691,15 +708,19 @@ describe('setupPluginHost', () => { const pluginDir = join(pluginsDir, 'devtools-sample-plugin') await mkdir(pluginDir, { recursive: true }) await writeFile( - join(pluginDir, pluginManifestFileName), - await readFile(join(samplePluginRoot, pluginManifestFileName), 'utf-8'), + join(pluginDir, extensionManifestFileName), + await readFile(join(samplePluginRoot, extensionManifestFileName), 'utf-8'), ) await writeFile( join(pluginDir, 'devtools-sample-plugin.mjs'), - await readFile(join(samplePluginRoot, 'devtools-sample-plugin.mjs'), 'utf-8'), + (await readFile(join(samplePluginRoot, 'devtools-sample-plugin.mjs'), 'utf-8')) + .replace( + '\'@proj-airi/plugin-sdk\'', + JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href), + ), ) - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -713,87 +734,58 @@ describe('setupPluginHost', () => { expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) }) - it('loads the chess-like demo plugin and exposes an active gamelet module snapshot', async () => { + it('loads the chess-like demo plugin and exposes a gamelet module snapshot', async () => { const pluginDir = join(pluginsDir, 'airi-plugin-game-chess') - await mkdir(pluginsDir, { recursive: true }) - try { - await stat(join(chessLikePluginRoot, 'dist')) - await cp(join(chessLikePluginRoot, 'dist'), pluginDir, { recursive: true }) - await symlink(join(chessLikePluginRoot, 'node_modules'), join(pluginDir, 'node_modules'), 'junction') - } - catch { - await mkdir(pluginDir, { recursive: true }) - await writeFile( - join(pluginDir, pluginManifestFileName), - JSON.stringify({ - apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'airi-plugin-game-chess', - 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'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, - ], - resources: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['read', 'write'] }, - ], - capabilities: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] }, - ], - }, - entrypoints: { - electron: './index.ts', - }, - }, null, 2), - ) - await writeFile( - join(pluginDir, 'index.ts'), - ` - export async function init(ctx) { - await ctx.apis.bindings.announce({ - moduleId: 'chess-like-main', - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - config: { - title: 'Chess', - entrypoint: 'ui/index.html', - widget: { - mount: 'iframe', - iframe: { - assetPath: 'ui/index.html', - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }, - }, - config: { - defaults: { - airiSide: 'white', - opening: 'queen-gambit', - }, - }, - widgets: [ - { - id: 'main-board', - kind: 'primary', - }, - ], - }, - }) - await ctx.apis.bindings.activate({ moduleId: 'chess-like-main' }) - } - `, - ) - await mkdir(join(pluginDir, 'ui'), { recursive: true }) - await writeFile(join(pluginDir, 'ui', 'index.html'), 'fallback') - } + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, extensionManifestFileName), + JSON.stringify({ + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-plugin-game-chess', + permissions: { + apis: [ + { key: 'kit.gamelet', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['write'] }, + ], + }, + entrypoints: { + electron: './airi-plugin-game-chess.mjs', + }, + }, null, 2), + ) + await writeFile(join(pluginDir, 'airi-plugin-game-chess.mjs'), [ + `import { defineExtension } from ${JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href)}`, + `import { gameletKit } from ${JSON.stringify(pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk-tamagotchi/src/index.ts')).href)}`, + '', + 'export default defineExtension({', + ' id: "airi-plugin-game-chess",', + ' async setup(ctx) {', + ' const module = await ctx.modules.register({', + ' id: "chess-like-main",', + ' permissions: {', + ' apis: [{ key: "kit.gamelet", actions: ["invoke"] }],', + ' resources: [{ key: "proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings", actions: ["write"] }],', + ' },', + ' })', + ' const gamelets = await module.kits.use(gameletKit)', + ' await gamelets.mount({', + ' title: "Chess",', + ' ui: {', + ' mount: "iframe",', + ' iframe: { assetPath: "ui/index.html", sandbox: "allow-scripts allow-same-origin allow-forms allow-popups" },', + ' },', + ' init: { airiSide: "white", opening: "queen-gambit" },', + ' })', + ' },', + '})', + ].join('\n')) + await mkdir(join(pluginDir, 'ui'), { recursive: true }) + await writeFile(join(pluginDir, 'ui', 'index.html'), 'fallback') - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -811,15 +803,14 @@ describe('setupPluginHost', () => { // Verify the host exposes the announced module snapshot after activation. expect(snapshot.modules).toEqual(expect.arrayContaining([ expect.objectContaining({ - moduleId: 'chess-like-main', + moduleId: 'chess-like-main:gamelet', ownerPluginId: 'airi-plugin-game-chess', kitId: 'kit.gamelet', kitModuleType: 'gamelet', runtime: 'electron', - state: 'active', + state: 'announced', config: expect.objectContaining({ title: 'Chess', - entrypoint: 'ui/index.html', widget: expect.objectContaining({ mount: 'iframe', iframe: expect.objectContaining({ @@ -830,25 +821,19 @@ describe('setupPluginHost', () => { sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }), }), - config: expect.objectContaining({ - defaults: expect.objectContaining({ + config: { + init: { airiSide: 'white', opening: 'queen-gambit', - }), - }), - widgets: expect.arrayContaining([ - expect.objectContaining({ - id: 'main-board', - kind: 'primary', - }), - ]), + }, + }, }), }), ])) }) it('exposes plugin asset base URL through Eventa invoke', async () => { - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeGetAssetBaseUrl = defineInvoke(contextState.lastContext!, electronPluginGetAssetBaseUrl) @@ -857,450 +842,6 @@ describe('setupPluginHost', () => { expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) }) - it('exposes registered plugin tools to renderer clients', async () => { - const service = await setupPluginHost() - const pluginDir = join(pluginsDir, 'test-plugin-tools') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-tools.ts', - contents: 'export async function init() {}', - }) - - const session = await service.host.start(createToolEnabledManifest(entrypointPath), { cwd: pluginDir }) - await session.apis.tools.register({ - tool: { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - activation: { - keywords: ['chess'], - patterns: ['play.*chess'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true }), - }) - await session.apis.tools.register({ - tool: { - id: 'end_play_chess', - title: 'End Play Chess', - description: 'End chess.', - activation: { - keywords: ['end chess'], - patterns: ['end.*chess'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true, ended: true }), - }) - - expect(contextState.lastContext).toBeDefined() - const invokeListAgentTools = defineInvoke(contextState.lastContext!, electronPluginListAgentTools) - const invokeListXsaiTools = defineInvoke(contextState.lastContext!, electronPluginListXsaiTools) - const invokePluginTool = defineInvoke(contextState.lastContext!, electronPluginInvokeTool) - - await expect(invokeListAgentTools()).resolves.toEqual([ - expect.objectContaining({ id: 'play_chess' }), - expect.objectContaining({ id: 'end_play_chess' }), - ]) - await expect(invokeListXsaiTools()).resolves.toEqual({ - prompts: [], - tools: [ - expect.objectContaining({ name: 'play_chess' }), - expect.objectContaining({ name: 'end_play_chess' }), - ], - }) - await expect(invokePluginTool({ - ownerPluginId: session.identity.plugin.id, - name: 'play_chess', - input: {}, - })).resolves.toEqual({ ok: true }) - }) - - it('lets a plugin tool drive host-backed gamelet widgets end-to-end', async () => { - const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() - const pluginDir = join(pluginsDir, 'test-plugin-gamelets') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-gamelets.ts', - contents: [ - 'const gameletId = \'gamelet-under-test\'', - '', - 'export async function init(ctx) {', - ' await ctx.apis.bindings.announce({', - ' moduleId: gameletId,', - ' kitId: \'kit.gamelet\',', - ' kitModuleType: \'gamelet\',', - ' config: {', - ' title: \'Gamelet Under Test\',', - ' entrypoint: \'ui/index.html\',', - ' widget: {', - ' mount: \'iframe\',', - ' iframe: {', - ' assetPath: \'ui/index.html\',', - ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', - ' },', - ' windowSize: {', - ' width: 980,', - ' height: 840,', - ' minWidth: 640,', - ' minHeight: 640,', - ' },', - ' },', - ' config: {', - ' defaults: {', - ' opening: \'queen-gambit\',', - ' },', - ' },', - ' },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId: gameletId })', - ' await ctx.apis.tools.register({', - ' tool: {', - ' id: \'drive_gamelet\',', - ' title: \'Drive Gamelet\',', - ' description: \'Drive a gamelet through host-backed APIs.\',', - ' activation: { keywords: [], patterns: [] },', - ' parameters: { type: \'object\', properties: {} },', - ' },', - ' async execute() {', - ' await ctx.apis.gamelets.open(gameletId, { mode: \'new\', side: \'white\' })', - ' await ctx.apis.gamelets.configure(gameletId, { opening: \'sicilian\', side: \'black\' })', - ' const state = await ctx.apis.gamelets.request(gameletId, { action: \'snapshot\' })', - ' const wasOpen = await ctx.apis.gamelets.isOpen(gameletId)', - ' await ctx.apis.gamelets.close(gameletId)', - '', - ' return { ok: true, wasOpen, state }', - ' },', - ' })', - '}', - ].join('\n'), - }) - - const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) - - expect(contextState.lastContext).toBeDefined() - const invokePluginTool = defineInvoke(contextState.lastContext!, electronPluginInvokeTool) - - await expect(invokePluginTool({ - ownerPluginId: session.identity.plugin.id, - name: 'drive_gamelet', - input: {}, - })).resolves.toEqual({ - ok: true, - wasOpen: true, - state: { - requestId: expect.any(String), - ready: true, - fen: 'fen-after-request', - }, - }) - - expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'gamelet-under-test', - componentName: 'extension-ui', - componentProps: expect.objectContaining({ - moduleId: 'gamelet-under-test', - title: 'Gamelet Under Test', - payload: { - mode: 'new', - side: 'white', - }, - }), - })) - expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'gamelet-under-test', - componentProps: expect.objectContaining({ - payload: { - mode: 'new', - side: 'black', - opening: 'sicilian', - }, - }), - })) - expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'gamelet-under-test', - componentProps: expect.objectContaining({ - payload: expect.objectContaining({ - command: { - action: 'snapshot', - requestId: expect.any(String), - }, - }), - }), - })) - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-under-test') - expect(widgetSnapshots.get('gamelet-under-test')).toBeUndefined() - expect(service.host.getBinding('gamelet-under-test')).toEqual(expect.objectContaining({ - config: expect.objectContaining({ - config: expect.objectContaining({ - defaults: { - opening: 'queen-gambit', - }, - current: { - opening: 'sicilian', - side: 'black', - }, - }), - }), - })) - }) - - it('updates widgetsManager through the host gamelet wrapper', async () => { - const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() - const pluginDir = join(pluginsDir, 'test-plugin-gamelets-wrapper') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-gamelets-wrapper.ts', - contents: [ - 'const gameletId = \'gamelet-wrapper-under-test\'', - '', - 'export async function init(ctx) {', - ' await ctx.apis.bindings.announce({', - ' moduleId: gameletId,', - ' kitId: \'kit.gamelet\',', - ' kitModuleType: \'gamelet\',', - ' config: {', - ' title: \'Gamelet Wrapper Under Test\',', - ' entrypoint: \'ui/index.html\',', - ' widget: {', - ' mount: \'iframe\',', - ' iframe: {', - ' assetPath: \'ui/index.html\',', - ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', - ' },', - ' windowSize: {', - ' width: 980,', - ' height: 840,', - ' minWidth: 640,', - ' minHeight: 640,', - ' },', - ' },', - ' config: {', - ' defaults: {', - ' opening: \'queen-gambit\',', - ' },', - ' },', - ' },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId: gameletId })', - '}', - ].join('\n'), - }) - - const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) - const gamelets = getGameletApis(session) - - await expect(gamelets.open('gamelet-wrapper-under-test', { mode: 'new', side: 'white' })).resolves.toBeUndefined() - expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'gamelet-wrapper-under-test', - componentName: 'extension-ui', - componentProps: expect.objectContaining({ - moduleId: 'gamelet-wrapper-under-test', - title: 'Gamelet Wrapper Under Test', - payload: { - mode: 'new', - side: 'white', - }, - }), - })) - expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toEqual(expect.objectContaining({ - componentProps: expect.objectContaining({ - payload: { - mode: 'new', - side: 'white', - }, - }), - })) - - await expect(gamelets.configure('gamelet-wrapper-under-test', { opening: 'sicilian', side: 'black' })).resolves.toBeUndefined() - expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ - id: 'gamelet-wrapper-under-test', - componentProps: expect.objectContaining({ - payload: { - mode: 'new', - side: 'black', - opening: 'sicilian', - }, - }), - })) - expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toEqual(expect.objectContaining({ - componentProps: expect.objectContaining({ - payload: { - mode: 'new', - side: 'black', - opening: 'sicilian', - }, - }), - })) - - await expect(gamelets.close('gamelet-wrapper-under-test')).resolves.toBeUndefined() - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-wrapper-under-test') - expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toBeUndefined() - }) - - it('removes open gamelet widgets when the owning session stops', async () => { - const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() - const pluginDir = join(pluginsDir, 'test-plugin-gamelets-stop-cleanup') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-gamelets-stop-cleanup.ts', - contents: [ - 'const gameletId = \'gamelet-stop-cleanup-under-test\'', - '', - 'export async function init(ctx) {', - ' await ctx.apis.bindings.announce({', - ' moduleId: gameletId,', - ' kitId: \'kit.gamelet\',', - ' kitModuleType: \'gamelet\',', - ' config: {', - ' title: \'Stop Cleanup Gamelet\',', - ' widget: {', - ' windowSize: { width: 720, height: 540 },', - ' },', - ' },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId: gameletId })', - '}', - ].join('\n'), - }) - - const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) - const gamelets = getGameletApis(session) - - await expect(gamelets.open('gamelet-stop-cleanup-under-test', { side: 'white' })).resolves.toBeUndefined() - expect(widgetSnapshots.get('gamelet-stop-cleanup-under-test')).toEqual(expect.objectContaining({ - id: 'gamelet-stop-cleanup-under-test', - })) - - service.host.stop(session.id) - - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-under-test') - expect(widgetSnapshots.get('gamelet-stop-cleanup-under-test')).toBeUndefined() - }) - - it('handles rejected widget cleanup promises while stopping a session', async () => { - const widgetSnapshots = new Map() - const widgetsManager = { - openWindow: vi.fn(async (_params?: { id?: string }) => {}), - pushWidget: vi.fn(async (payload: WidgetsAddPayload) => { - const snapshot: WidgetSnapshot = { - id: payload.id ?? Math.random().toString(36).slice(2, 10), - componentName: payload.componentName, - componentProps: payload.componentProps ?? {}, - size: payload.size ?? 'm', - windowSize: payload.windowSize, - ttlMs: payload.ttlMs ?? 0, - } - - widgetSnapshots.set(snapshot.id, snapshot) - return snapshot.id - }), - updateWidget: vi.fn(async (_payload: WidgetsUpdatePayload) => {}), - removeWidget: vi.fn(async (id: string) => { - if (id === 'gamelet-stop-cleanup-reject-a') { - throw new Error('remove failed') - } - - widgetSnapshots.delete(id) - }), - getWidgetSnapshot: vi.fn((id: string) => widgetSnapshots.get(id)), - publishWidgetEvent: vi.fn((_id: string, _event: Record) => {}), - onWidgetEvent: vi.fn((_listener: (event: { id: string, event: Record }) => void) => () => {}), - } - const service = await setupPluginHostService({ widgetsManager }) - const pluginDir = join(pluginsDir, 'test-plugin-gamelets-stop-cleanup-reject') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-gamelets-stop-cleanup-reject.ts', - contents: [ - 'export async function init(ctx) {', - ' await ctx.apis.bindings.announce({', - ' moduleId: \'gamelet-stop-cleanup-reject-a\',', - ' kitId: \'kit.gamelet\',', - ' kitModuleType: \'gamelet\',', - ' config: { title: \'Reject A\', widget: { windowSize: { width: 720, height: 540 } } },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId: \'gamelet-stop-cleanup-reject-a\' })', - ' await ctx.apis.bindings.announce({', - ' moduleId: \'gamelet-stop-cleanup-reject-b\',', - ' kitId: \'kit.gamelet\',', - ' kitModuleType: \'gamelet\',', - ' config: { title: \'Reject B\', widget: { windowSize: { width: 720, height: 540 } } },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId: \'gamelet-stop-cleanup-reject-b\' })', - '}', - ].join('\n'), - }) - - const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) - const gamelets = getGameletApis(session) - - await expect(gamelets.open('gamelet-stop-cleanup-reject-a', { side: 'white' })).resolves.toBeUndefined() - await expect(gamelets.open('gamelet-stop-cleanup-reject-b', { side: 'black' })).resolves.toBeUndefined() - - expect(() => service.host.stop(session.id)).not.toThrow() - await new Promise(resolve => setTimeout(resolve, 0)) - - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-reject-a') - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-reject-b') - expect(widgetSnapshots.get('gamelet-stop-cleanup-reject-b')).toBeUndefined() - }) - - it('rejects gamelet access when plugin id matches but session id does not', async () => { - const { service } = await setupPluginHostForTest() - const pluginDir = join(pluginsDir, 'test-plugin-gamelets-isolation') - await mkdir(pluginDir, { recursive: true }) - const entrypointPath = await writeEntrypoint({ - dir: pluginDir, - name: 'test-plugin-gamelets-isolation.ts', - contents: 'export async function init() {}', - }) - - const manifest = createToolDrivenGameletManifest(entrypointPath) - const first = await service.host.start(manifest, { cwd: pluginDir }) - service.host.announceBinding(first.id, { - moduleId: 'isolated-gamelet', - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - config: { - title: 'Isolated Gamelet', - entrypoint: 'ui/index.html', - widget: { - mount: 'iframe', - iframe: { - assetPath: 'ui/index.html', - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }, - windowSize: { - width: 980, - height: 840, - minWidth: 640, - minHeight: 640, - }, - }, - }, - }) - - const second = await service.host.start(manifest, { cwd: pluginDir }) - const secondGamelets = getGameletApis(second) - - await expect(secondGamelets.isOpen('isolated-gamelet')).rejects.toThrow( - `Gamelet module \`isolated-gamelet\` is not owned by session \`${second.id}\`.`, - ) - }) - it('rewrites plugin widget iframe asset URLs in inspect snapshots', async () => { const pluginDir = join(pluginsDir, 'test-plugin-widget-asset-url') await mkdir(pluginDir, { recursive: true }) @@ -1312,66 +853,26 @@ describe('setupPluginHost', () => { const entrypointFile = await writeEntrypoint({ dir: pluginDir, name: 'test-plugin-widget-asset-url.ts', - contents: [ - 'const moduleId = \'widget-shell-under-test\'', - '', - 'export async function init(ctx) {', - ' await ctx.apis.bindings.announce({', - ' moduleId,', - ' kitId: \'kit.widget\',', - ' kitModuleType: \'window\',', - ' config: {', - ' title: \'Widget Shell Under Test\',', - ' entrypoint: \'./ui/index.html\',', - ' widget: {', - ' mount: \'iframe\',', - ' iframe: {', - ' assetPath: \'./ui/index.html\',', - ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', - ' },', - ' windowSize: {', - ' width: 980,', - ' height: 840,', - ' minWidth: 640,', - ' minHeight: 640,', - ' },', - ' },', - ' },', - ' })', - ' await ctx.apis.bindings.activate({ moduleId })', - '}', - ].join('\n'), + contents: createEmptyExtensionEntrypoint('test-plugin-widget-asset-url'), }) - await writeFile(join(pluginDir, pluginManifestFileName), JSON.stringify({ + await writeFile(join(pluginDir, extensionManifestFileName), JSON.stringify({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin-widget-asset-url', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-plugin-widget-asset-url', 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'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, + { key: 'kit.widget', actions: ['invoke'] }, ], resources: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, - { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, ], - capabilities: [ - { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] }, - ], }, entrypoints: { electron: `./${basename(entrypointFile)}`, }, }, null, 2)) - await setupPluginHost() + const { service } = await setupExtensionHostForTest() expect(contextState.lastContext).toBeDefined() const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) @@ -1380,6 +881,34 @@ describe('setupPluginHost', () => { await invokeSetEnabled({ name: 'test-plugin-widget-asset-url', enabled: true }) await invokeLoadEnabled() + const session = service.host + .listSessions() + .find(item => item.extension.id === 'test-plugin-widget-asset-url') + if (!session) { + throw new Error('Expected widget asset URL test extension to be loaded.') + } + service.host.bindExtensionKitModule(session.id, { + moduleId: 'widget-shell-under-test', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { + title: 'Widget Shell Under Test', + entrypoint: './ui/index.html', + widget: { + mount: 'iframe', + iframe: { + assetPath: './ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + windowSize: { + width: 980, + height: 840, + minWidth: 640, + minHeight: 640, + }, + }, + }, + }) const snapshot = await invokeInspect() expect(snapshot.modules).toEqual(expect.arrayContaining([ @@ -1404,12 +933,17 @@ describe('setupPluginHost', () => { }), ])) - const iframeSource = (snapshot.modules.find(module => module.moduleId === 'widget-shell-under-test')?.config as Record) + const iframeSource = (snapshot.modules.find(module => module.moduleId === 'widget-shell-under-test')?.config as Record) ?.widget - ?.iframe - ?.src as string | undefined - expect(iframeSource).toBeTruthy() - expect(iframeSource).not.toContain('?t=') + const iframeRecord = iframeSource && typeof iframeSource === 'object' && !Array.isArray(iframeSource) + ? (iframeSource as Record).iframe + : undefined + const iframeUrlSource = iframeRecord && typeof iframeRecord === 'object' && !Array.isArray(iframeRecord) + ? (iframeRecord as Record).src + : undefined + const iframeUrlString = typeof iframeUrlSource === 'string' ? iframeUrlSource : undefined + expect(iframeUrlString).toBeTruthy() + expect(iframeUrlString).not.toContain('?t=') expect(sessionMock.defaultSession.cookies.set).toHaveBeenCalledOnce() const setCookie = sessionMock.defaultSession.cookies.set.mock.calls.at(0)?.[0] as { name: string, value: string } | undefined @@ -1417,10 +951,10 @@ describe('setupPluginHost', () => { throw new Error('Expected plugin asset cookie to be set before iframe URL is returned') } const cookieHeader = `${setCookie.name}=${setCookie.value}` - const iframeWithoutCookieResponse = await fetch(iframeSource!) + const iframeWithoutCookieResponse = await fetch(iframeUrlString!) expect(iframeWithoutCookieResponse.status).toBe(401) - const iframeResponse = await fetch(iframeSource!, { + const iframeResponse = await fetch(iframeUrlString!, { headers: { cookie: cookieHeader, }, @@ -1428,7 +962,7 @@ describe('setupPluginHost', () => { expect(iframeResponse.status).toBe(200) expect(await iframeResponse.text()).toContain('widget') - const iframeUrl = new URL(iframeSource!) + const iframeUrl = new URL(iframeUrlString!) const outsideSessionUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/private/secret.txt` const outsideSessionResponse = await fetch(outsideSessionUrl, { headers: { @@ -1439,7 +973,7 @@ describe('setupPluginHost', () => { }) it('mirrors degraded and withdrawn capability updates into the host snapshot', async () => { - await setupPluginHost() + await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) @@ -1477,14 +1011,18 @@ describe('setupPluginHost', () => { }) it('includes built-in kits and module snapshots in inspect responses without leaking mutable references', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - const { host } = await setupPluginHost() + const { host } = await setupExtensionHost() expect(contextState.lastContext).toBeDefined() const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) - const session = await host.start(createDynamicModuleManifest(normalEntrypoint), { cwd: pluginsDir }) - host.announceBinding(session.id, { + const dynamicEntrypoint = await writeEntrypoint({ + dir: pluginsDir, + name: 'test-dynamic-module.ts', + contents: createEmptyExtensionEntrypoint('test-dynamic-module'), + }) + const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) + host.bindExtensionKitModule(session.id, { moduleId: 'widget-shell', kitId: 'kit.widget', kitModuleType: 'window', @@ -1570,11 +1108,384 @@ describe('setupPluginHost', () => { }) }) - it('rejects module announce when the kit runtime does not match the host runtime', async () => { - const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') - const { host } = await setupPluginHost() + /** + * @example + * expect(service.host.getBinding('kit-module:gamelet')).toEqual(expect.objectContaining({ kitId: 'kit.gamelet' })) + */ + it('injects host services into defineExtension gamelet kit clients', async () => { + const { service } = await setupExtensionHostForTest() + const pluginDir = join(pluginsDir, 'test-extension-gamelet-kit') + await mkdir(pluginDir, { recursive: true }) + const pluginSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk/src/index.ts')).href + const tamagotchiSdkUrl = pathToFileURL(resolve(repoRoot, 'packages/plugin-sdk-tamagotchi/src/index.ts')).href + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-extension-gamelet-kit.ts', + contents: [ + `import { defineExtension } from '${pluginSdkUrl}'`, + `import { gameletKit } from '${tamagotchiSdkUrl}'`, + '', + 'export default defineExtension({', + ' id: \'test-extension-gamelet-kit\',', + ' async setup(ctx) {', + ' const module = await ctx.modules.register({', + ' id: \'kit-module\',', + ' permissions: {', + ' apis: [{ key: \'kit.gamelet\', actions: [\'invoke\'] }],', + ' resources: [{ key: \'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings\', actions: [\'write\'] }],', + ' },', + ' })', + ' const gamelets = await module.kits.use(gameletKit)', + ' await gamelets.mount({', + ' title: \'Kit Runtime Gamelet\',', + ' ui: gamelets.iframe({ assetPath: \'ui/index.html\' }),', + ' })', + ' },', + '})', + ].join('\n'), + }) - const session = await host.start(createDynamicModuleManifest(normalEntrypoint), { cwd: pluginsDir }) + const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath), { cwd: pluginDir }) + const binding = service.host.getBinding('kit-module:gamelet') + + expect(binding).toEqual(expect.objectContaining({ + moduleId: 'kit-module:gamelet', + ownerPluginId: 'test-extension-gamelet-kit', + ownerSessionId: session.id, + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + })) + expect(binding?.config).toEqual({ + title: 'Kit Runtime Gamelet', + widget: { + mount: 'iframe', + iframe: { + assetPath: 'ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + }, + config: { + init: {}, + }, + }) + }) + + /** + * @example + * expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ id: 'kit-module:board' })) + * expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ id: 'kit-module:board' })) + */ + it('injects gamelet orchestration methods backed by the widget manager', async () => { + const { service, widgetsManager } = await setupExtensionHostForTest() + const pluginDir = join(pluginsDir, 'test-extension-gamelet-orchestration') + await mkdir(pluginDir, { recursive: true }) + await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') + await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-extension-gamelet-orchestration.ts', + contents: [ + 'import { defineExtension } from \'@proj-airi/plugin-sdk\'', + 'import { gameletKit } from \'@proj-airi/plugin-sdk-tamagotchi\'', + '', + 'export default defineExtension({', + ' id: \'test-extension-gamelet-orchestration\',', + ' async setup(ctx) {', + ' const module = await ctx.modules.register({', + ' id: \'kit-module\',', + ' permissions: {', + ' apis: [{ key: \'kit.gamelet\', actions: [\'invoke\'] }],', + ' resources: [{ key: \'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings\', actions: [\'write\'] }],', + ' },', + ' })', + ' const gamelets = await module.kits.use(gameletKit)', + ' await gamelets.mount({', + ' bindingId: \'kit-module:board\',', + ' title: \'Kit Runtime Gamelet\',', + ' ui: gamelets.iframe({ assetPath: \'ui/index.html\' }),', + ' })', + ' await gamelets.orchestration.open(\'kit-module:board\', { mode: \'new\' })', + ' await gamelets.orchestration.open(\'kit-module:board\', { mode: \'resume\' })', + ' await gamelets.orchestration.configure(\'kit-module:board\', { command: { requestId: \'ignored-by-test-double\' } })', + ' const snapshot = await gamelets.orchestration.request(\'kit-module:board\', { action: \'snapshot\' }, { timeoutMs: 1000 })', + ' if (snapshot.fen !== \'fen-after-request\') {', + ' throw new Error(\'Expected request to resolve from response event\')', + ' }', + ' if (!(await gamelets.orchestration.isOpen(\'kit-module:board\'))) {', + ' throw new Error(\'Expected gamelet to be open before close\')', + ' }', + ' await gamelets.orchestration.close(\'kit-module:board\')', + ' },', + '})', + ].join('\n'), + }) + + await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-orchestration'), { cwd: pluginDir }) + + expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'kit-module:board', + componentName: 'extension-ui', + componentProps: { + moduleId: 'kit-module:board', + payload: { mode: 'new' }, + }, + size: 'l', + })) + expect(widgetsManager.openWindow).toHaveBeenCalledWith({ id: 'kit-module:board' }) + expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ + id: 'kit-module:board', + componentProps: { + moduleId: 'kit-module:board', + payload: { mode: 'resume' }, + }, + size: 'l', + }) + expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ + id: 'kit-module:board', + componentProps: { + moduleId: 'kit-module:board', + payload: { command: { requestId: 'ignored-by-test-double' } }, + }, + }) + expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ + id: 'kit-module:board', + componentProps: { + moduleId: 'kit-module:board', + payload: { + request: { + route: { + namespace: 'airi.plugin.gamelet', + name: 'request', + }, + responseRoute: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + requestId: expect.any(String), + payload: { action: 'snapshot' }, + }, + }, + }, + }) + expect(widgetsManager.getWidgetSnapshot).toHaveBeenCalledWith('kit-module:board') + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board') + }) + + /** + * @example + * expect(widgetsManager.removeWidget).toHaveBeenCalledWith('chess:board') + */ + it('closes mounted gamelets when the owning extension session stops', async () => { + const { service, widgetsManager } = await setupExtensionHostForTest() + const pluginDir = join(pluginsDir, 'test-extension-gamelet-session-cleanup') + await mkdir(pluginDir, { recursive: true }) + await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk') + await linkWorkspacePackageForPlugin(pluginDir, '@proj-airi/plugin-sdk-tamagotchi') + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-extension-gamelet-session-cleanup.ts', + contents: [ + 'import { createModule, defineExtension } from \'@proj-airi/plugin-sdk\'', + 'import { createGamelet } from \'@proj-airi/plugin-sdk-tamagotchi/kits/gamelet\'', + '', + 'export default defineExtension({', + ' id: \'test-extension-gamelet-session-cleanup\',', + ' async setup(ctx) {', + ' const chess = await createModule(ctx, { id: \'chess\' })', + ' const board = await createGamelet(chess, {', + ' id: \'board\',', + ' title: \'Chess\',', + ' indexPath: \'ui/index.html\',', + ' })', + ' await board.open({ mode: \'new\' })', + ' },', + '})', + ].join('\n'), + }) + + const session = await service.host.start(createExtensionGameletKitManifest(entrypointPath, 'test-extension-gamelet-session-cleanup'), { cwd: pluginDir }) + await service.host.stop(session.id) + + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('chess:board') + }) + + /** + * @example + * await expect(request).rejects.toThrow('Gamelet request failed.') + */ + it('rejects gamelet requests when the iframe response reports failure', async () => { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await gamelets.open('kit-module:board') + const request = gamelets.request('kit-module:board', { action: 'snapshot' }) + const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0] + const requestEnvelope = updatePayload?.componentProps?.payload?.request + if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') { + throw new Error('Expected gamelet request envelope in widget props.') + } + + widgetsManager.publishWidgetEvent('kit-module:board', { + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + payload: { + requestId: requestEnvelope.requestId, + ok: false, + message: 'Board rejected the snapshot request.', + }, + }) + + await expect(request).rejects.toThrow('Board rejected the snapshot request.') + gamelets.dispose() + }) + + /** + * @example + * await expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.') + */ + it('uses the default gamelet request timeout when no timeout is provided', async () => { + vi.useFakeTimers() + try { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await gamelets.open('kit-module:board') + const request = gamelets.request('kit-module:board', { action: 'snapshot' }) + const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.') + await vi.advanceTimersByTimeAsync(30000) + + await rejection + gamelets.dispose() + } + finally { + vi.useRealTimers() + } + }) + + /** + * @example + * await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') + */ + it('rejects gamelet requests immediately when the widget is not open', async () => { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') + expect(widgetsManager.updateWidget).not.toHaveBeenCalled() + gamelets.dispose() + }) + + /** + * @example + * await expect(request).resolves.toEqual(expect.objectContaining({ fen: 'fen-after-request' })) + */ + it('ignores gamelet responses from a different widget id', async () => { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await gamelets.open('kit-module:board') + const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) + const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0] + const requestEnvelope = updatePayload?.componentProps?.payload?.request + if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') { + throw new Error('Expected gamelet request envelope in widget props.') + } + + widgetsManager.publishWidgetEvent('kit-module:other-board', { + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + payload: { + requestId: requestEnvelope.requestId, + fen: 'wrong-board', + }, + }) + await Promise.resolve() + + widgetsManager.publishWidgetEvent('kit-module:board', { + type: 'response', + requestId: requestEnvelope.requestId, + fen: 'legacy-top-level', + }) + await Promise.resolve() + + widgetsManager.publishWidgetEvent('kit-module:board', { + route: { + namespace: 'airi.plugin.other', + name: 'response', + }, + payload: { + requestId: requestEnvelope.requestId, + fen: 'wrong-namespace', + }, + }) + await Promise.resolve() + + widgetsManager.publishWidgetEvent('kit-module:board', { + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + payload: { + requestId: requestEnvelope.requestId, + fen: 'fen-after-request', + }, + }) + + await expect(request).resolves.toEqual({ fen: 'fen-after-request' }) + gamelets.dispose() + }) + + /** + * @example + * await expect(request).rejects.toThrow('Gamelet was closed before the request completed.') + */ + it('rejects pending gamelet requests when the widget closes', async () => { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await gamelets.open('kit-module:board') + const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) + const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.') + await gamelets.close('kit-module:board') + + await rejection + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board') + gamelets.dispose() + }) + + /** + * @example + * expect(unsubscribe).toHaveBeenCalled() + * await expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.') + */ + it('unsubscribes and rejects pending gamelet requests on dispose', async () => { + const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + const unsubscribe = vi.fn() + widgetsManager.onWidgetEvent.mockReturnValueOnce(unsubscribe) + const gamelets = createGameletOrchestrationRuntime(widgetsManager) + + await gamelets.open('kit-module:board') + const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) + const rejection = expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.') + gamelets.dispose() + + expect(unsubscribe).toHaveBeenCalled() + await rejection + }) + + it('rejects module announce when the kit runtime does not match the host runtime', async () => { + const { host } = await setupExtensionHost() + + const dynamicEntrypoint = await writeEntrypoint({ + dir: pluginsDir, + name: 'test-dynamic-module.ts', + contents: createEmptyExtensionEntrypoint('test-dynamic-module'), + }) + const session = await host.start(createDynamicModuleManifest(dynamicEntrypoint), { cwd: pluginsDir }) host.registerKit({ kitId: 'kit.web-only', version: '1.0.0', @@ -1582,7 +1493,7 @@ describe('setupPluginHost', () => { capabilities: [{ key: 'kit.web-only.module', actions: ['announce'] }], }) - expect(() => host.announceBinding(session.id, { + expect(() => host.bindExtensionKitModule(session.id, { moduleId: 'web-only-shell', kitId: 'kit.web-only', kitModuleType: 'window', diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index 066ec8b8d..64234bd0b 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -1,6 +1,6 @@ import type { - PluginHostService, - SetupPluginHostOptions, + ExtensionHostService, + SetupExtensionHostOptions, } from './types' import { @@ -35,27 +35,27 @@ import { electronPluginListAgentTools, electronPluginListXsaiTools, } from '../../../../shared/eventa/plugin/tools' -import { setupPluginHostHostService } from './host' +import { setupExtensionHostServiceInternal } from './host' /** - * Initializes the Electron plugin host and wires IPC handlers. + * Initializes the Electron extension host and wires IPC handlers. * Call once during app startup; it loads manifests, returns the host instance, * and registers Eventa handlers for listing, enabling, and loading plugins. * - * Loads plugin manifests from the app config directory under `plugins/v1`. + * Loads extension manifests from the app config directory under `extensions/v1`. * - * - Windows: %APPDATA%\${appId}\plugins\v1 - * - Linux: $XDG_CONFIG_HOME/${appId}/plugins/v1 or ~/.config/${appId}/plugins/v1 - * - macOS: ~/Library/Application Support/${appId}/plugins/v1 + * - Windows: %APPDATA%\${appId}\extensions\v1 + * - Linux: $XDG_CONFIG_HOME/${appId}/extensions/v1 or ~/.config/${appId}/extensions/v1 + * - macOS: ~/Library/Application Support/${appId}/extensions/v1 * - * Persists enablement/known state to `plugins-v1.json` alongside config data. + * Persists enablement/known state to `extensions-v1.json` alongside config data. * - * - Windows: %APPDATA%\${appId}/plugins-v1.json - * - Linux: $XDG_CONFIG_HOME/${appId}/plugins-v1.json or ~/.config/${appId}/plugins-v1.json - * - macOS: ~/Library/Application Support/${appId}/plugins-v1.json + * - Windows: %APPDATA%\${appId}/extensions-v1.json + * - Linux: $XDG_CONFIG_HOME/${appId}/extensions-v1.json or ~/.config/${appId}/extensions-v1.json + * - macOS: ~/Library/Application Support/${appId}/extensions-v1.json */ -export async function setupPluginHost(options: SetupPluginHostOptions): Promise { - const hostService = await setupPluginHostHostService(options) +export async function setupExtensionHost(options: SetupExtensionHostOptions): Promise { + const hostService = await setupExtensionHostServiceInternal(options) const { context } = createContext(ipcMain) const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders) @@ -92,15 +92,15 @@ export async function setupPluginHost(options: SetupPluginHostOptions): Promise< }) defineInvokeHandler(context, electronPluginListAgentTools, async () => { - return await hostService.host.listAvailableToolDescriptors() + return await hostService.tools.listAvailableDescriptors() }) defineInvokeHandler(context, electronPluginListXsaiTools, async () => { - return await hostService.host.listSerializedXsaiTools() + return await hostService.tools.listSerializedXsaiTools() }) defineInvokeHandler(context, electronPluginInvokeTool, async (payload) => { - return await hostService.host.invokeTool(payload.ownerPluginId, payload.name, payload.input) + return await hostService.tools.invoke(payload.ownerPluginId, payload.name, payload.input) }) defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => { diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts deleted file mode 100644 index 3af44a2cd..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { - BindingRecord, - HostDataRecord, - PluginHost, -} from '@proj-airi/plugin-sdk/plugin-host' - -import type { WidgetWindowSize } from '../../../../../../shared/eventa' - -import { isPlainObject } from 'es-toolkit' - -function cloneRecord(value: TValue): TValue { - return structuredClone(value) -} - -function toRecord(value: unknown): Record | undefined { - return isPlainObject(value) ? cloneRecord(value as Record) : undefined -} - -function toHostDataRecord(value: unknown): HostDataRecord | undefined { - return isPlainObject(value) ? cloneRecord(value as HostDataRecord) : undefined -} - -function toWindowSize(value: unknown): WidgetWindowSize | undefined { - if (!isPlainObject(value)) { - return undefined - } - - if (typeof value.width !== 'number' || typeof value.height !== 'number') { - return undefined - } - - return cloneRecord(value as WidgetWindowSize) -} - -/** - * Resolves one owned gamelet binding and rejects mismatched ownership. - * - * Use when: - * - Plugin sessions invoke `session.apis.gamelets.*` - * - The gamelet kit must enforce plugin and session ownership before touching widget state - * - * Expects: - * - `host` is the active plugin host instance - * - `moduleId` refers to a binding announced through `kit.gamelet` - * - * Returns: - * - The owned gamelet binding record when ownership and kit checks pass - */ -export function getOwnedGameletBindingOrThrow(params: { - host: PluginHost - ownerPluginId: string - ownerSessionId: string - moduleId: string -}): BindingRecord { - const binding = params.host.getBinding(params.moduleId) - if (!binding) { - throw new Error(`Gamelet module not found: ${params.moduleId}`) - } - - if (binding.ownerPluginId !== params.ownerPluginId) { - throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by plugin \`${params.ownerPluginId}\`.`) - } - - if (binding.ownerSessionId !== params.ownerSessionId) { - throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by session \`${params.ownerSessionId}\`.`) - } - - if (binding.kitId !== 'kit.gamelet') { - throw new Error(`Module \`${params.moduleId}\` is not a gamelet binding.`) - } - - return binding -} - -/** - * Derives the widget window size for one gamelet binding. - * - * Use when: - * - Opening or reconfiguring a gamelet-backed widget - * - Preferring module config while preserving current window size when the config omits it - * - * Expects: - * - `moduleConfig` is the binding config stored on the host - * - * Returns: - * - The configured window size or the current widget snapshot size - */ -export function getGameletWidgetWindowSize(params: { - moduleConfig: HostDataRecord - existingSnapshot?: { windowSize?: unknown } -}): WidgetWindowSize | undefined { - const widgetConfig = toRecord(params.moduleConfig.widget) - const windowSize = toWindowSize(widgetConfig?.windowSize) - return windowSize ?? toWindowSize(params.existingSnapshot?.windowSize) -} - -/** - * Derives the current display title for a gamelet widget. - * - * Use when: - * - Opening or updating a widget-backed gamelet - * - Preserving a current title when the binding config does not provide one - * - * Expects: - * - `moduleId` is the stable fallback title when neither config nor widget props define one - * - * Returns: - * - The best available title for the widget shell - */ -export function getGameletTitle(params: { - moduleId: string - moduleConfig: HostDataRecord - existingComponentProps?: Record -}): string { - const configuredTitle = typeof params.moduleConfig.title === 'string' && params.moduleConfig.title.trim() - ? params.moduleConfig.title - : undefined - const currentTitle = typeof params.existingComponentProps?.title === 'string' && params.existingComponentProps.title.trim() - ? params.existingComponentProps.title - : undefined - - return configuredTitle ?? currentTitle ?? params.moduleId -} - -/** - * Reads the persisted gamelet config payload stored under `config.current`. - * - * Use when: - * - Hydrating widget payload state from the host binding config - * - Merging `gamelets.configure(...)` patches into the stored config - * - * Expects: - * - `moduleConfig` is the full binding config record for one gamelet - * - * Returns: - * - The stored config payload, or an empty record when it has not been set yet - */ -export function getStoredGameletConfig(moduleConfig: HostDataRecord): HostDataRecord { - const configSection = toHostDataRecord(moduleConfig.config) - return toHostDataRecord(configSection?.current) ?? {} -} - -/** - * Merges one `gamelets.configure(...)` patch into the stored binding config. - * - * Use when: - * - Updating the host binding config and the mirrored widget payload together - * - * Expects: - * - `patch` is a JSON-compatible config patch - * - * Returns: - * - The merged `current` payload and the next full binding config record - */ -export function mergeGameletConfigPatch(params: { - moduleConfig: HostDataRecord - patch: HostDataRecord -}): { - nextCurrentConfig: HostDataRecord - nextConfig: HostDataRecord -} { - const nextCurrentConfig: HostDataRecord = { - ...getStoredGameletConfig(params.moduleConfig), - ...cloneRecord(params.patch), - } - const nextConfig: HostDataRecord = { - ...cloneRecord(params.moduleConfig), - config: { - ...toHostDataRecord(params.moduleConfig.config), - current: nextCurrentConfig, - }, - } - - return { - nextCurrentConfig, - nextConfig, - } -} - -/** - * Builds the extension-ui component props used for one gamelet widget. - * - * Use when: - * - Opening or updating a gamelet-backed extension-ui widget - * - Preserving unrelated existing component props while replacing payload-specific fields - * - * Expects: - * - `moduleId` and `title` are already resolved for the current binding state - * - * Returns: - * - The next component props payload sent to the widgets manager - */ -export function createGameletWidgetProps(params: { - moduleId: string - title: string - payload?: Record - windowSize?: WidgetWindowSize - existingComponentProps?: Record -}): Record { - return { - ...params.existingComponentProps, - moduleId: params.moduleId, - title: params.title, - ...(params.windowSize ? { windowSize: params.windowSize } : {}), - ...(params.payload ? { payload: params.payload } : {}), - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts index f36de8467..7cd6f2a15 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts @@ -1,140 +1,10 @@ -import type { - HostDataRecord, - KitDescriptor, - PluginHost, - PluginHostContribution, -} from '@proj-airi/plugin-sdk/plugin-host' - -import type { PluginHostGameletWidgetsManager } from '../../types' - -import { isPlainObject } from 'es-toolkit' - -import { - createGameletWidgetProps, - getGameletTitle, - getGameletWidgetWindowSize, - getOwnedGameletBindingOrThrow, - getStoredGameletConfig, - mergeGameletConfigPatch, -} from './gamelet-widget-state' - -/** - * Identifies the stage-tamagotchi permission key used to open a host-backed gamelet surface. - * - * Use when: - * - Declaring or asserting permission for `session.apis.gamelets.open(...)` - * - Reusing the stable gamelet event key in stage-owned tests - * - * Expects: - * - The gamelet kit contribution and its tests share this stage-owned constant - * - * Returns: - * - The permission/event key string for opening gamelets - */ -export const pluginGameletApiOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:open' - -/** - * Identifies the stage-tamagotchi permission key used to update a host-backed gamelet surface. - * - * Use when: - * - Declaring or asserting permission for `session.apis.gamelets.configure(...)` - * - Reusing the stable gamelet event key in stage-owned tests - * - * Expects: - * - The gamelet kit contribution and its tests share this stage-owned constant - * - * Returns: - * - The permission/event key string for configuring gamelets - */ -export const pluginGameletApiConfigureEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:configure' - -/** - * Identifies the stage-tamagotchi permission key used to request data from a host-backed gamelet surface. - * - * Use when: - * - Declaring or asserting permission for `session.apis.gamelets.request(...)` - * - Waiting for an iframe gamelet to process a host command and publish a response - * - * Expects: - * - The gamelet kit contribution and its tests share this stage-owned constant - * - * Returns: - * - The permission/event key string for request-response gamelet commands - */ -export const pluginGameletApiRequestEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:request' - -/** - * Identifies the stage-tamagotchi permission key used to close a host-backed gamelet surface. - * - * Use when: - * - Declaring or asserting permission for `session.apis.gamelets.close(...)` - * - Reusing the stable gamelet event key in stage-owned tests - * - * Expects: - * - The gamelet kit contribution and its tests share this stage-owned constant - * - * Returns: - * - The permission/event key string for closing gamelets - */ -export const pluginGameletApiCloseEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:close' - -/** - * Identifies the stage-tamagotchi permission key used to query whether a gamelet is open. - * - * Use when: - * - Declaring or asserting permission for `session.apis.gamelets.isOpen(...)` - * - Reusing the stable gamelet event key in stage-owned tests - * - * Expects: - * - The gamelet kit contribution and its tests share this stage-owned constant - * - * Returns: - * - The permission/event key string for querying gamelets - */ -export const pluginGameletApiIsOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:is-open' - -function cloneRecord(value: TValue): TValue { - return structuredClone(value) -} - -function toRecord(value: unknown): Record | undefined { - return isPlainObject(value) ? cloneRecord(value as Record) : undefined -} - -/** - * Creates an opaque request id for correlating one iframe command response. - * - * Use when: - * - A plugin tool needs a one-shot reply from a gamelet iframe - * - * Expects: - * - Request ids only need to be unique within one live Electron process - * - * Returns: - * - A stable string safe to pass through JSON-like host data records - */ -function createGameletRequestId(): string { - const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 12) - return `gamelet:${Date.now()}:${random}` -} - -function getEventPayload(event: Record): Record | undefined { - return toRecord(event.payload) -} - -function getPositiveTimeoutMs(timeoutMs: number | undefined): number { - if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { - return 15_000 - } - - return timeoutMs -} +import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-host' /** * Declares the built-in gamelet kit exposed by `stage-tamagotchi`. * * Use when: - * - Bootstrapping the Electron plugin host with gamelet support + * - Bootstrapping the Electron extension host with gamelet support * - Reading the stable built-in gamelet kit descriptor in tests or snapshots * * Expects: @@ -156,315 +26,15 @@ export const gameletPluginKitDescriptor = { * Registers the built-in gamelet kit on one host instance. * * Use when: - * - Bootstrapping the Electron plugin host with gamelet kit support + * - Bootstrapping the Electron extension host with gamelet kit support * - Keeping gamelet descriptor registration inside the gamelet kit module * * Expects: - * - `host` is the initialized plugin host instance + * - `host` is the initialized extension host instance * * Returns: * - The registered gamelet kit descriptor */ -export function registerGameletPluginKit(host: PluginHost): KitDescriptor { +export function registerGameletPluginKit(host: ExtensionHost): KitDescriptor { return host.registerKit(gameletPluginKitDescriptor) } - -/** - * Creates the installable gamelet host contribution for `session.apis.gamelets`. - * - * Use when: - * - `stage-tamagotchi` needs plugin sessions to open, configure, close, or inspect gamelet widgets - * - The root plugin host bootstrap should consume a kit-owned contribution instead of embedding gamelet logic - * - * Expects: - * - `attachHost(...)` is called immediately after constructing `PluginHost` - * - `widgetsManager` already manages extension-ui widget state - * - * Returns: - * - A contribution plus an attach step that binds it to the constructed host instance - */ -export function createGameletHostContribution(options: { - widgetsManager: PluginHostGameletWidgetsManager -}): { - attachHost: (host: PluginHost) => void - contribution: PluginHostContribution -} { - let host: PluginHost | undefined - const openWidgetIdsBySession = new Map>() - const cleanupPromisesBySession = new Map>() - - const requireHost = () => { - if (!host) { - throw new Error('Gamelet host contribution has not been attached to a PluginHost instance.') - } - - return host - } - - const trackOpenWidget = (sessionId: string, moduleId: string) => { - const widgetIds = openWidgetIdsBySession.get(sessionId) ?? new Set() - widgetIds.add(moduleId) - openWidgetIdsBySession.set(sessionId, widgetIds) - } - - const untrackOpenWidget = (sessionId: string, moduleId: string) => { - const widgetIds = openWidgetIdsBySession.get(sessionId) - if (!widgetIds) { - return - } - - widgetIds.delete(moduleId) - if (widgetIds.size === 0) { - openWidgetIdsBySession.delete(sessionId) - } - } - - return { - attachHost(instance) { - host = instance - }, - contribution: { - install(context) { - context.registerLifecycleHook('session-stopped', ({ session }) => { - const widgetIds = openWidgetIdsBySession.get(session.sessionId) - if (!widgetIds) { - return - } - - const widgetIdsToRemove = [...widgetIds] - const cleanupPromise = Promise - .allSettled(widgetIdsToRemove.map(widgetId => options.widgetsManager.removeWidget(widgetId))) - .then(() => { - openWidgetIdsBySession.delete(session.sessionId) - cleanupPromisesBySession.delete(session.sessionId) - }) - - cleanupPromisesBySession.set(session.sessionId, cleanupPromise) - void cleanupPromise.catch(() => {}) - }) - - context.registerSessionApi('gamelets', ({ session, assertPermission }) => ({ - async open(id: string, params?: HostDataRecord) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: pluginGameletApiOpenEventName, - }) - - const module = getOwnedGameletBindingOrThrow({ - host: requireHost(), - ownerPluginId: session.ownerPluginId, - ownerSessionId: session.sessionId, - moduleId: id, - }) - const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) - const existingComponentProps = toRecord(existingSnapshot?.componentProps) - const payload = params - ? cloneRecord(params) - : (toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config)) - const windowSize = getGameletWidgetWindowSize({ - moduleConfig: module.config, - existingSnapshot, - }) - const componentProps = createGameletWidgetProps({ - moduleId: id, - title: getGameletTitle({ - moduleId: id, - moduleConfig: module.config, - existingComponentProps, - }), - payload, - windowSize, - existingComponentProps, - }) - - if (existingSnapshot) { - await options.widgetsManager.updateWidget({ - id, - componentProps, - windowSize, - }) - await options.widgetsManager.openWindow({ id }) - trackOpenWidget(session.sessionId, id) - return - } - - await options.widgetsManager.pushWidget({ - id, - componentName: 'extension-ui', - componentProps, - size: 'm', - ttlMs: 0, - windowSize, - }) - trackOpenWidget(session.sessionId, id) - }, - async configure(id: string, patch: HostDataRecord) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: pluginGameletApiConfigureEventName, - }) - - const module = getOwnedGameletBindingOrThrow({ - host: requireHost(), - ownerPluginId: session.ownerPluginId, - ownerSessionId: session.sessionId, - moduleId: id, - }) - const { nextConfig } = mergeGameletConfigPatch({ - moduleConfig: module.config, - patch, - }) - - requireHost().updateBinding(module.ownerSessionId, id, { config: nextConfig }) - - const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) - if (!existingSnapshot) { - return - } - - const existingComponentProps = toRecord(existingSnapshot.componentProps) - const existingPayload = toRecord(existingComponentProps?.payload) ?? {} - const windowSize = getGameletWidgetWindowSize({ - moduleConfig: nextConfig, - existingSnapshot, - }) - - await options.widgetsManager.updateWidget({ - id, - componentProps: createGameletWidgetProps({ - moduleId: id, - title: getGameletTitle({ - moduleId: id, - moduleConfig: nextConfig, - existingComponentProps, - }), - payload: { - ...existingPayload, - ...cloneRecord(patch), - }, - windowSize, - existingComponentProps, - }), - windowSize, - }) - }, - async request(id: string, payload: HostDataRecord, requestOptions?: { timeoutMs?: number }) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: pluginGameletApiRequestEventName, - }) - - const module = getOwnedGameletBindingOrThrow({ - host: requireHost(), - ownerPluginId: session.ownerPluginId, - ownerSessionId: session.sessionId, - moduleId: id, - }) - const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) - if (!existingSnapshot) { - throw new Error(`Gamelet widget \`${id}\` is not open.`) - } - - const requestId = createGameletRequestId() - const command = { - ...cloneRecord(payload), - requestId, - } - const timeoutMs = getPositiveTimeoutMs(requestOptions?.timeoutMs) - const responsePromise = new Promise((resolve, reject) => { - let isSettled = false - let dispose: (() => void) | undefined - const timer = setTimeout(() => { - if (isSettled) { - return - } - - isSettled = true - dispose?.() - reject(new Error(`Gamelet request \`${requestId}\` timed out for widget \`${id}\`.`)) - }, timeoutMs) - - dispose = options.widgetsManager.onWidgetEvent((event) => { - if (event.id !== id || isSettled) { - return - } - - const response = getEventPayload(event.event) - if (response?.requestId !== requestId) { - return - } - - isSettled = true - clearTimeout(timer) - dispose?.() - resolve(response as HostDataRecord) - }) - }) - - const existingComponentProps = toRecord(existingSnapshot.componentProps) - const existingPayload = toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config) - const windowSize = getGameletWidgetWindowSize({ - moduleConfig: module.config, - existingSnapshot, - }) - - await options.widgetsManager.updateWidget({ - id, - componentProps: createGameletWidgetProps({ - moduleId: id, - title: getGameletTitle({ - moduleId: id, - moduleConfig: module.config, - existingComponentProps, - }), - payload: { - ...existingPayload, - command, - }, - windowSize, - existingComponentProps, - }), - windowSize, - }) - - return await responsePromise - }, - async close(id: string) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: pluginGameletApiCloseEventName, - }) - - getOwnedGameletBindingOrThrow({ - host: requireHost(), - ownerPluginId: session.ownerPluginId, - ownerSessionId: session.sessionId, - moduleId: id, - }) - await options.widgetsManager.removeWidget(id) - untrackOpenWidget(session.sessionId, id) - }, - async isOpen(id: string) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: pluginGameletApiIsOpenEventName, - }) - - getOwnedGameletBindingOrThrow({ - host: requireHost(), - ownerPluginId: session.ownerPluginId, - ownerSessionId: session.sessionId, - moduleId: id, - }) - return options.widgetsManager.getWidgetSnapshot(id) !== undefined - }, - })) - }, - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts new file mode 100644 index 000000000..4c12b3665 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts @@ -0,0 +1,237 @@ +import type { GameletKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/gamelet' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' + +import type { ExtensionHostGameletWidgetsManager } from '../../types' + +import { randomUUID } from 'node:crypto' + +import { errorMessageFrom } from '@moeru/std' + +const DEFAULT_REQUEST_TIMEOUT_MS = 30000 +const GAMELET_ROUTE_NAMESPACE = 'airi.plugin.gamelet' + +export interface GameletOrchestrationRuntime extends NonNullable { + dispose: () => void +} + +interface PendingRequest { + bindingId: string + resolve: (value: unknown) => void + reject: (error: Error) => void + timeout: ReturnType +} + +/** + * Creates the Electron host implementation for gamelet lifecycle and request calls. + * + * Use when: + * - Built-in `kit.gamelet` clients need to open iframe-backed extension UI widgets + * - Extension-side gamelet handles need request/response orchestration through widget events + * + * Expects: + * - Widget ids are the same values as gamelet binding ids + * - Widget-side response events echo the original `requestId` at the top level or under `payload` + * + * Returns: + * - A gamelet orchestration runtime backed by the stage widget manager + */ +export function createGameletOrchestrationRuntime( + widgetsManager: ExtensionHostGameletWidgetsManager, +): GameletOrchestrationRuntime { + const pendingRequests = new Map() + + const rejectPendingForBinding = (bindingId: string, message: string) => { + for (const [requestId, pending] of pendingRequests.entries()) { + if (pending.bindingId !== bindingId) { + continue + } + + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + pending.reject(new Error(message)) + } + } + + const rejectAllPending = (message: string) => { + for (const [requestId, pending] of pendingRequests.entries()) { + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + pending.reject(new Error(message)) + } + } + + const unsubscribe = widgetsManager.onWidgetEvent(({ id, event }) => { + const response = readRequestResponse(event) + if (!response) { + return + } + + const pending = pendingRequests.get(response.requestId) + if (!pending || pending.bindingId !== id) { + return + } + + pendingRequests.delete(response.requestId) + clearTimeout(pending.timeout) + + if (response.ok === false) { + pending.reject(new Error(readResponseErrorMessage(response.value))) + return + } + + pending.resolve(response.value) + }) + + return { + async open(bindingId, payload) { + const componentProps = createComponentProps(bindingId, payload ?? {}) + + if (widgetsManager.getWidgetSnapshot(bindingId)) { + await widgetsManager.updateWidget({ + id: bindingId, + componentProps, + size: 'l', + }) + } + else { + await widgetsManager.pushWidget({ + id: bindingId, + componentName: 'extension-ui', + componentProps, + size: 'l', + }) + } + + await widgetsManager.openWindow({ id: bindingId }) + }, + async configure(bindingId, payload) { + await widgetsManager.updateWidget({ + id: bindingId, + componentProps: createComponentProps(bindingId, payload), + }) + }, + async request(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise { + if (!widgetsManager.getWidgetSnapshot(bindingId)) { + throw new Error(`Gamelet \`${bindingId}\` is not open.`) + } + + const requestId = randomUUID() + const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingRequests.delete(requestId) + reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`)) + }, timeoutMs) + + pendingRequests.set(requestId, { + bindingId, + resolve: value => resolve(value as TResponse), + reject, + timeout, + }) + }) + + try { + await widgetsManager.updateWidget({ + id: bindingId, + componentProps: createComponentProps(bindingId, { + request: { + route: { + namespace: GAMELET_ROUTE_NAMESPACE, + name: 'request', + }, + responseRoute: { + namespace: GAMELET_ROUTE_NAMESPACE, + name: 'response', + }, + requestId, + payload, + }, + }), + }) + } + catch (error) { + const pending = pendingRequests.get(requestId) + if (pending) { + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + pending.reject(new Error(errorMessageFrom(error) ?? 'Failed to publish gamelet request.')) + } + } + + return await response + }, + async close(bindingId) { + rejectPendingForBinding(bindingId, 'Gamelet was closed before the request completed.') + await widgetsManager.removeWidget(bindingId) + }, + async isOpen(bindingId) { + return Boolean(widgetsManager.getWidgetSnapshot(bindingId)) + }, + dispose() { + unsubscribe() + rejectAllPending('Gamelet orchestration runtime was disposed before the request completed.') + }, + } +} + +function createComponentProps(bindingId: string, payload: HostDataRecord): HostDataRecord { + return { + moduleId: bindingId, + payload, + } +} + +function readRequestResponse(event: Record): { requestId: string, ok?: boolean, value: unknown } | undefined { + const route = event.route + if (!route || typeof route !== 'object' || Array.isArray(route)) { + return undefined + } + + const routeRecord = route as Record + if (routeRecord.namespace !== GAMELET_ROUTE_NAMESPACE || routeRecord.name !== 'response') { + return undefined + } + + const payload = event.payload + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return undefined + } + + const payloadRecord = payload as Record + if (typeof payloadRecord.requestId !== 'string') { + return undefined + } + + return { + requestId: payloadRecord.requestId, + ok: typeof payloadRecord.ok === 'boolean' ? payloadRecord.ok : undefined, + value: readResponseValue(payloadRecord), + } +} + +function readResponseValue(response: Record): unknown { + if ('result' in response) { + return response.result + } + + const { type: _type, requestId: _requestId, ...value } = response + return value +} + +function readResponseErrorMessage(response: unknown): string { + if (!response || typeof response !== 'object' || Array.isArray(response)) { + return 'Gamelet request failed.' + } + + const responseRecord = response as Record + if (typeof responseRecord.error === 'string') { + return responseRecord.error + } + if (typeof responseRecord.message === 'string') { + return responseRecord.message + } + + return 'Gamelet request failed.' +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts index 6a8b51bd3..50215c60c 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts @@ -1,15 +1,86 @@ -import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import type { KitRef } from '@proj-airi/plugin-sdk' +import type { ToolKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/tools' +import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host' -import type { SetupPluginHostOptions } from '../types' +import type { SetupExtensionHostOptions } from '../types' +import type { GameletOrchestrationRuntime } from './gamelet/orchestration' -import { - createGameletHostContribution, - registerGameletPluginKit, -} from './gamelet' +import { gameletKit, toolKit } from '@proj-airi/plugin-sdk-tamagotchi' +import { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools' + +import { registerGameletPluginKit } from './gamelet' +import { createGameletOrchestrationRuntime } from './gamelet/orchestration' import { registerWidgetPluginKit } from './widget' +type GameletKitClient = ReturnType +type ToolKitClient = ReturnType + +function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef { + return { + ...gameletKit, + createClient(runtime) { + const hostRuntime = { + ...runtime, + bindings: { + bind: (input: Parameters[1]) => options.host.bindExtensionKitModule(runtime.sessionId, input, runtime.moduleId), + }, + gamelets: options.gamelets, + } + + return gameletKit.createClient(hostRuntime) + }, + } +} + +function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef { + return { + ...toolKit, + createClient(runtime) { + let cleanupRegistered = false + const ensureCleanup = () => { + if (cleanupRegistered) { + return + } + + cleanupRegistered = true + runtime.subscriptions.add({ + dispose: () => { + options.tools.unregisterOwnerScope(runtime.sessionId, runtime.moduleId) + }, + }) + } + + const hostRuntime: ToolKitRuntime = { + ...runtime, + tools: { + register: (input) => { + ensureCleanup() + options.tools.register({ + ownerSessionId: runtime.sessionId, + ownerPluginId: runtime.extensionId, + ownerModuleId: runtime.moduleId, + ...input, + }) + }, + registerToolsetPrompt: (input) => { + ensureCleanup() + options.tools.registerToolsetPrompt({ + ownerSessionId: runtime.sessionId, + ownerPluginId: runtime.extensionId, + ownerModuleId: runtime.moduleId, + toolset: input, + }) + }, + }, + } + + return toolKit.createClient(hostRuntime) + }, + } +} + /** - * Creates the built-in kit runtime installed by the Electron plugin host. + * Creates the built-in kit runtime installed by the Electron extension host. * * Use when: * - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline @@ -21,23 +92,31 @@ import { registerWidgetPluginKit } from './widget' * Returns: * - Helpers to attach contributions and register built-in kits on the host */ -export function createBuiltInPluginKitRuntime(options: SetupPluginHostOptions): { - contributions: ReturnType['contribution'][] - attachHost: (host: PluginHost) => void - registerHostKits: (host: PluginHost) => void +export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): { + contributions: [] + attachHost: (host: ExtensionHost) => void + registerHostKits: (host: ExtensionHost) => void + tools: TamagotchiToolRegistry + dispose: () => void } { - const gameletContribution = createGameletHostContribution({ - widgetsManager: options.widgetsManager, - }) + const gamelets = createGameletOrchestrationRuntime(options.widgetsManager) + const tools = new TamagotchiToolRegistry() return { - contributions: [gameletContribution.contribution], - attachHost(host) { - gameletContribution.attachHost(host) + contributions: [], + attachHost(_host) { + void options.widgetsManager }, registerHostKits(host) { registerWidgetPluginKit(host) registerGameletPluginKit(host) + host.registerKitApi(createHostGameletKit({ host, gamelets })) + host.registerKitApi(createHostToolKit({ tools })) + }, + tools, + dispose() { + gamelets.dispose() + tools.clear() }, } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts index 8d5793d06..ad0cf0f3f 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts @@ -12,7 +12,7 @@ import { * Describes one widget iframe asset as seen from the mounted `/ui` route. * * Use when: - * - Converting plugin config asset paths into mounted extension asset URLs + * - Converting extension config asset paths into mounted extension asset URLs * - Creating sessions that must validate against route-relative asset paths * * Expects: diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts index 0526d692d..5f73c6913 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts @@ -1,6 +1,6 @@ import type { + ExtensionHost, KitDescriptor, - PluginHost, } from '@proj-airi/plugin-sdk/plugin-host' export { @@ -12,7 +12,7 @@ export { * Declares the built-in widget kit exposed by `stage-tamagotchi`. * * Use when: - * - Bootstrapping the Electron plugin host with widget support + * - Bootstrapping the Electron extension host with widget support * - Reading the stable built-in widget kit descriptor in tests or snapshots * * Expects: @@ -34,15 +34,15 @@ export const widgetPluginKitDescriptor = { * Registers the built-in widget kit on one host instance. * * Use when: - * - Bootstrapping the Electron plugin host with widget kit support + * - Bootstrapping the Electron extension host with widget kit support * - Keeping widget descriptor registration inside the widget kit module * * Expects: - * - `host` is the initialized plugin host instance + * - `host` is the initialized extension host instance * * Returns: * - The registered widget kit descriptor */ -export function registerWidgetPluginKit(host: PluginHost): KitDescriptor { +export function registerWidgetPluginKit(host: ExtensionHost): KitDescriptor { return host.registerKit(widgetPluginKitDescriptor) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts index 01ed15a15..1c1d62073 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts @@ -1,4 +1,4 @@ -import type { ManifestV1, PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import type { ExtensionHost, ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' import type { WidgetsAddPayload, @@ -7,37 +7,37 @@ import type { } from '../../../../shared/eventa' /** - * Runtime-facing plugin host service bundle returned by setup. + * Runtime-facing extension host service bundle returned by setup. * * Use when: - * - Bootstrapping plugin infrastructure during Electron startup + * - Bootstrapping extension infrastructure during Electron startup * - Accessing loaded manifests after host initialization * * Expects: - * - `host` is an initialized Electron runtime plugin host + * - `host` is an initialized Electron runtime extension host * - `manifests` reflect the latest loaded manifest snapshot at setup time * * Returns: * - A stable object containing host instance and manifest list */ -export interface PluginHostService { - host: PluginHost - manifests: ManifestV1[] +export interface ExtensionHostService { + host: ExtensionHost + manifests: ExtensionManifestV1[] } /** - * Describes the widget manager surface required by plugin-driven gamelet APIs. + * Describes the widget manager surface required by extension-driven gamelet APIs. * * Use when: - * - `setupPluginHost(...)` needs to open, update, or close extension-ui widgets + * - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets * * Expects: * - Widget ids remain stable and may be reused for the same module id * * Returns: - * - The minimal widget-manager contract consumed by the plugin host service + * - The minimal widget-manager contract consumed by the extension host service */ -export interface PluginHostGameletWidgetsManager { +export interface ExtensionHostGameletWidgetsManager { openWindow: (params?: { id?: string }) => Promise pushWidget: (payload: WidgetsAddPayload) => Promise updateWidget: (payload: WidgetsUpdatePayload) => Promise @@ -48,11 +48,11 @@ export interface PluginHostGameletWidgetsManager { } /** - * Configures the runtime dependencies required by `setupPluginHost(...)`. + * Configures the runtime dependencies required by `setupExtensionHost(...)`. * * Use when: - * - Wiring the plugin host during Electron startup - * - Providing test doubles for plugin-driven gamelet orchestration + * - Wiring the extension host during Electron startup + * - Providing test doubles for extension-driven gamelet orchestration * * Expects: * - `widgetsManager` is already initialized and ready to manage overlay widgets @@ -60,12 +60,12 @@ export interface PluginHostGameletWidgetsManager { * Returns: * - N/A */ -export interface SetupPluginHostOptions { - widgetsManager: PluginHostGameletWidgetsManager +export interface SetupExtensionHostOptions { + widgetsManager: ExtensionHostGameletWidgetsManager } /** - * Binding announcement payload used by plugin-side runtime registration. + * Binding announcement payload used by extension-side runtime registration. * * Use when: * - Announcing a new module for a registered kit @@ -79,7 +79,7 @@ export interface SetupPluginHostOptions { * Returns: * - N/A */ -export interface PluginHostBindingAnnounceInput { +export interface ExtensionHostBindingAnnounceInput { moduleId: string kitId: string kitModuleType: string @@ -99,26 +99,26 @@ export interface PluginHostBindingAnnounceInput { * Returns: * - N/A */ -export interface PluginHostBindingListOptions { +export interface ExtensionHostBindingListOptions { ownerSessionId?: string kitId?: string } /** - * Persisted plugin configuration snapshot. + * Persisted extension configuration snapshot. * * Use when: * - Reading/writing enabled and auto-reload plugin state - * - Keeping known plugin manifest path metadata + * - Keeping known extension manifest path metadata * * Expects: - * - Arrays contain plugin manifest names + * - Arrays contain extension manifest names * - `known` maps plugin names to canonical manifest paths * * Returns: * - N/A */ -export interface PluginConfig { +export interface ExtensionConfig { enabled: string[] autoReload: string[] known: Record @@ -128,20 +128,20 @@ export interface PluginConfig { * Internal manifest record with resolved location and package version. * * Use when: - * - Loading plugin manifests from disk + * - Loading extension manifests from disk * - Resolving runtime entrypoints and extension asset metadata * * Expects: * - `manifest` is schema-validated - * - `path` points to `plugin.airi.json` - * - `rootDir` is the plugin root directory + * - `path` points to `extension.airi.json` + * - `rootDir` is the extension root directory * - `version` is discovered from package metadata or fallback * * Returns: * - N/A */ export interface ManifestEntry { - manifest: ManifestV1 + manifest: ExtensionManifestV1 path: string rootDir: string version: string diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts index da485a2f1..2325e48e1 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/shared/eventa-runtime.test.ts @@ -127,4 +127,112 @@ describe('createContext', () => { host.dispose() iframe.dispose() }) + + /** + * @example + * expect(initPayload.props.request.responseRoute).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' }) + * expect(publishedPayload.route).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' }) + */ + it('relays gamelet request props and iframe response envelopes over the extension UI bridge', async () => { + const parentWindow = new MockWindow() + const iframeWindow = new MockWindow() + parentWindow.peer = iframeWindow + iframeWindow.peer = parentWindow + + const host = createContext({ + channel: 'test:extension-ui', + currentWindow: parentWindow as unknown as Window, + expectedSource: () => iframeWindow as unknown as Window, + targetWindow: () => iframeWindow as unknown as Window, + }) + const iframe = createContext({ + channel: 'test:extension-ui', + currentWindow: iframeWindow as unknown as Window, + expectedSource: () => parentWindow as unknown as Window, + targetWindow: () => parentWindow as unknown as Window, + }) + + const initPayload = new Promise>((resolve) => { + iframe.context.on(widgetsIframeInitEvent, (event) => { + const request = event.body?.props?.request + if (!request || typeof request !== 'object' || Array.isArray(request)) { + return + } + + resolve(request as Record) + }) + }) + + host.context.emit(widgetsIframeInitEvent, { + moduleId: 'chess:board', + config: {}, + module: undefined, + props: { + request: { + route: { + namespace: 'airi.plugin.gamelet', + name: 'request', + }, + responseRoute: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + requestId: 'req-1', + payload: { + action: 'snapshot', + }, + }, + }, + }) + + await expect(initPayload).resolves.toEqual({ + route: { + namespace: 'airi.plugin.gamelet', + name: 'request', + }, + responseRoute: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + requestId: 'req-1', + payload: { + action: 'snapshot', + }, + }) + + const publishedPayload = new Promise>((resolve) => { + host.context.on(widgetsIframePublishEvent, (event) => { + if (!event.body) { + return + } + + resolve(event.body) + }) + }) + + iframe.context.emit(widgetsIframePublishEvent, { + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + payload: { + requestId: 'req-1', + fen: 'fen-after-request', + }, + }) + + await expect(publishedPayload).resolves.toEqual({ + route: { + namespace: 'airi.plugin.gamelet', + name: 'response', + }, + payload: { + requestId: 'req-1', + fen: 'fen-after-request', + }, + }) + + host.dispose() + iframe.dispose() + }) }) diff --git a/apps/stage-tamagotchi/tsconfig.json b/apps/stage-tamagotchi/tsconfig.json index 550f93241..77b5f360b 100644 --- a/apps/stage-tamagotchi/tsconfig.json +++ b/apps/stage-tamagotchi/tsconfig.json @@ -22,6 +22,12 @@ ], "@proj-airi/stage-layouts/*": [ "../../packages/stage-layouts/src/*" + ], + "@proj-airi/electron-vueuse": [ + "../../packages/electron-vueuse/src/index.ts" + ], + "@proj-airi/electron-vueuse/*": [ + "../../packages/electron-vueuse/src/*" ] }, "resolveJsonModule": true, diff --git a/packages/core-agent/src/agents/spark-notify/event-source.ts b/packages/core-agent/src/agents/spark-notify/event-source.ts index 36820ef30..ca73a04c1 100644 --- a/packages/core-agent/src/agents/spark-notify/event-source.ts +++ b/packages/core-agent/src/agents/spark-notify/event-source.ts @@ -6,13 +6,14 @@ interface EventSourcePayload { } function formatMetadataSource(source?: MetadataEventSource) { - if (!source?.plugin) + if (!source) return undefined - const pluginId = source.plugin.id - const instanceId = source.id + if ('extension' in source) { + return `${source.extension.id}:${source.id}` + } - return instanceId ? `${pluginId}:${instanceId}` : pluginId + return source.id } /** @@ -20,7 +21,7 @@ function formatMetadataSource(source?: MetadataEventSource) { * * Before: * - `{ source: "minecraft" }` - * - `{ metadata: { source: { plugin: { id: "p" }, id: "i" } } }` + * - `{ metadata: { source: { extension: { id: "p" }, id: "i" } } }` * * After: * - `"minecraft"` diff --git a/packages/core-agent/src/runtime/context-registry.test.ts b/packages/core-agent/src/runtime/context-registry.test.ts index 66655090c..792a6e429 100644 --- a/packages/core-agent/src/runtime/context-registry.test.ts +++ b/packages/core-agent/src/runtime/context-registry.test.ts @@ -7,13 +7,12 @@ import { createContextRegistry } from './context-registry' type TestContextMessage = ContextMessage & { source?: string } -function createMetadata(pluginId: string, instanceId: string): NonNullable { +function createMetadata(extensionId: string, moduleId: string): NonNullable { return { source: { - id: instanceId, - kind: 'plugin', - plugin: { - id: pluginId, + id: moduleId, + extension: { + id: extensionId, }, }, } @@ -105,20 +104,16 @@ describe('createContextRegistry', () => { /** * @example - * metadata.source.plugin.id + metadata.source.id becomes "plugin:instance". + * metadata.source.extension.id + metadata.source.id becomes "extension:module". */ it('resolves metadata source keys before source fallback and unknown fallback', () => { const registry = createContextRegistry() - const pluginInstanceResult = registry.ingest(createContextMessage({ + const extensionModuleResult = registry.ingest(createContextMessage({ id: 'with-instance', source: 'fallback-source', metadata: createMetadata('weather', 'station-1'), })) - const pluginOnlyResult = registry.ingest(createContextMessage({ - id: 'plugin-only', - metadata: createMetadata('weather', ''), - })) const sourceResult = registry.ingest(createContextMessage({ id: 'source-only', source: 'legacy-source', @@ -127,13 +122,11 @@ describe('createContextRegistry', () => { id: 'unknown-source', })) - expect(pluginInstanceResult?.sourceKey).toBe('weather:station-1') - expect(pluginOnlyResult?.sourceKey).toBe('weather') + expect(extensionModuleResult?.sourceKey).toBe('weather:station-1') expect(sourceResult?.sourceKey).toBe('legacy-source') expect(unknownResult?.sourceKey).toBe('unknown') expect(Object.keys(registry.snapshot())).toEqual([ 'weather:station-1', - 'weather', 'legacy-source', 'unknown', ]) diff --git a/packages/core-agent/src/runtime/context-registry.ts b/packages/core-agent/src/runtime/context-registry.ts index 336c6fb40..17bbaaf14 100644 --- a/packages/core-agent/src/runtime/context-registry.ts +++ b/packages/core-agent/src/runtime/context-registry.ts @@ -56,19 +56,20 @@ interface CreateContextRegistryOptions { /** * Resolves a context message into a stable source bucket key. * - * @default metadata plugin/instance key, then event source, then "unknown" + * @default metadata extension/module key, then event source, then "unknown" */ getSourceKey?: (event: EventSourcePayload, fallback?: string) => string } function formatMetadataSource(source?: MetadataEventSource) { - if (!source?.plugin) + if (!source) return undefined - const pluginId = source.plugin.id - const instanceId = source.id + if ('extension' in source) { + return `${source.extension.id}:${source.id}` + } - return instanceId ? `${pluginId}:${instanceId}` : pluginId + return source.id } function defaultGetSourceKey(event: EventSourcePayload, fallback = 'unknown') { diff --git a/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts b/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts index 15370eb4c..cd95672bb 100644 --- a/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts +++ b/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts @@ -1,4 +1,5 @@ import type { InvokeEventa } from '@moeru/eventa' +import type { ShallowRef } from 'vue' import { defineInvoke } from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/renderer' @@ -27,7 +28,7 @@ export function getElectronEventaContext(ipcRenderer?: IpcRendererLike): EventaC return sharedContext } -export function useElectronEventaContext(ipcRenderer?: IpcRendererLike) { +export function useElectronEventaContext(ipcRenderer?: IpcRendererLike): ShallowRef { return shallowRef(getElectronEventaContext(ipcRenderer)) } diff --git a/packages/plugin-protocol/src/types/events.test.ts b/packages/plugin-protocol/src/types/events.test.ts new file mode 100644 index 000000000..616c90642 --- /dev/null +++ b/packages/plugin-protocol/src/types/events.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { + extensionAnnounce, + extensionKitAnnounce, + extensionModuleAnnounce, + peerAuthenticate, +} from './events' + +describe('extension runtime protocol events', () => { + it('defines peer transport authentication separately from extension authentication', () => { + expect(peerAuthenticate.id).toBe('peer:authenticate') + }) + + it('defines extension session announcement separately from module announcement', () => { + expect(extensionAnnounce.id).toBe('extension:announce') + expect(extensionModuleAnnounce.id).toBe('extension:module:announce') + }) + + it('defines kit availability events under extension kit namespace', () => { + expect(extensionKitAnnounce.id).toBe('extension:kit:announce') + }) +}) diff --git a/packages/plugin-protocol/src/types/events.ts b/packages/plugin-protocol/src/types/events.ts index b150981ce..7949f7f4f 100644 --- a/packages/plugin-protocol/src/types/events.ts +++ b/packages/plugin-protocol/src/types/events.ts @@ -28,7 +28,7 @@ export interface PluginIdentity { */ version?: string /** - * Optional labels attached to the plugin manifest. + * Optional labels attached to the extension manifest. * Example: { env: "prod", app: "telegram", devtools: "true" }. */ labels?: Record @@ -55,7 +55,72 @@ export interface ModuleIdentity { labels?: Record } -export type MetadataEventSource = ModuleIdentity +/** + * Identifies an extension package/session that is loaded by an extension host. + * + * Extension identity is the package/session-level scope. Modules registered by + * the extension get their own {@link ExtensionModuleIdentity}. + */ +export interface ExtensionIdentity { + /** + * Stable extension identifier from `extension.airi.json`. + */ + id: string + /** + * Optional semantic version for the extension package. + */ + version?: string + /** + * Optional runtime session id assigned by the host for this loaded extension. + */ + sessionId?: string + /** + * Optional labels used for routing, inspection, and policy selectors. + */ + labels?: Record +} + +/** + * Identifies one runtime module registered by an extension setup function. + */ +export interface ExtensionModuleIdentity { + /** + * Stable module id within one extension session. + */ + id: string + /** + * Owning extension session identity. + */ + extension: ExtensionIdentity + /** + * Optional labels used for routing, inspection, and policy selectors. + */ + labels?: Record +} + +/** + * Identifies a kit API surface that can be used by extension modules. + */ +export interface ExtensionKitIdentity { + /** + * Stable kit id. + */ + id: string + /** + * Optional semantic version for compatibility checks. + */ + version?: string + /** + * Optional owner for future extension-provided kits. Host-provided kits omit this field. + */ + ownerExtension?: ExtensionIdentity + /** + * Optional labels used for routing, inspection, and policy selectors. + */ + labels?: Record +} + +export type MetadataEventSource = ModuleIdentity | ExtensionIdentity | ExtensionModuleIdentity | ExtensionKitIdentity /** * Static schema metadata for module configuration. @@ -588,6 +653,57 @@ export type WithOutputSource = { // 10) module:status (ready) // 11) module:status:change (to re-run phases) +interface PeerAuthenticateEvent { + token?: string + peerId?: string +} + +interface PeerAuthenticatedEvent { + authenticated: boolean + peerId: string +} + +interface PeerStatusEvent { + peerId: string + phase: 'connected' | 'authenticated' | 'de-authenticated' | 'closed' | 'failed' + reason?: string +} + +interface PeerDeAuthenticatedEvent { + peerId: string + reason?: string +} + +interface ExtensionAuthenticateEvent { + identity: ExtensionIdentity + token?: string +} + +interface ExtensionAuthenticatedEvent { + identity: ExtensionIdentity + authenticated: boolean + reason?: string +} + +interface ExtensionAnnounceEvent { + identity: ExtensionIdentity + permissions?: ModulePermissionDeclaration +} + +interface ExtensionModuleAnnounceEvent { + name: string + identity: ExtensionModuleIdentity + possibleEvents: Array<(keyof ProtocolEvents)> + permissions?: ModulePermissionDeclaration + configSchema?: ModuleConfigSchema + dependencies?: ModuleDependency[] +} + +interface ExtensionKitAnnounceEvent { + identity: ExtensionKitIdentity + capabilities?: ModuleCapability[] +} + interface ModuleAuthenticateEvent { token: string } @@ -614,7 +730,7 @@ export interface RegistryModulesSyncEvent { modules: Array<{ name: string index?: number - identity: ModuleIdentity + identity: MetadataEventSource }> } @@ -651,14 +767,14 @@ interface ModuleDeAnnouncedEvent { interface RegistryModulesHealthUnhealthyEvent { name: string index?: number - identity: ModuleIdentity + identity: MetadataEventSource reason?: string } interface RegistryModulesHealthHealthyEvent { name: string index?: number - identity: ModuleIdentity + identity: MetadataEventSource } @@ -743,7 +859,7 @@ interface ModulePermissionsDeniedEvent { * Emitted with the module's reconciled current permission snapshot. * * Typical use cases: - * - bootstrapping plugin runtime state after startup or reload + * - bootstrapping extension runtime state after startup or reload * - synchronizing UI/debug tools with the final requested vs granted view * * Protocol expectations: @@ -1042,6 +1158,25 @@ interface TransportConnectionHeartbeatEvent { type ContextUpdateEvent = ContextUpdate +export const peerAuthenticate = defineEventa('peer:authenticate') +export const peerAuthenticated = defineEventa('peer:authenticated') +export const peerStatus = defineEventa('peer:status') +export const peerDeAuthenticated = defineEventa('peer:de-authenticated') + +export const extensionAuthenticate = defineEventa('extension:authenticate') +export const extensionAuthenticated = defineEventa('extension:authenticated') +export const extensionAnnounce = defineEventa('extension:announce') +export const extensionAnnounced = defineEventa('extension:announced') +export const extensionDeAnnounced = defineEventa('extension:de-announced') + +export const extensionModuleAnnounce = defineEventa('extension:module:announce') +export const extensionModuleAnnounced = defineEventa('extension:module:announced') +export const extensionModuleDeAnnounced = defineEventa('extension:module:de-announced') + +export const extensionKitAnnounce = defineEventa('extension:kit:announce') +export const extensionKitAnnounced = defineEventa('extension:kit:announced') +export const extensionKitDeAnnounced = defineEventa('extension:kit:de-announced') + export const moduleAuthenticate = defineEventa('module:authenticate') export const moduleAuthenticated = defineEventa('module:authenticated') export const moduleCompatibilityRequest = defineEventa('module:compatibility:request') @@ -1161,6 +1296,23 @@ export interface ProtocolEvents { 'error': ErrorEvent 'error:permission': ErrorPermissionEvent + 'peer:authenticate': PeerAuthenticateEvent + 'peer:authenticated': PeerAuthenticatedEvent + 'peer:status': PeerStatusEvent + 'peer:de-authenticated': PeerDeAuthenticatedEvent + + 'extension:authenticate': ExtensionAuthenticateEvent + 'extension:authenticated': ExtensionAuthenticatedEvent + 'extension:announce': ExtensionAnnounceEvent + 'extension:announced': ExtensionAnnounceEvent + 'extension:de-announced': ExtensionAnnounceEvent & { reason?: string } + 'extension:module:announce': ExtensionModuleAnnounceEvent + 'extension:module:announced': ExtensionModuleAnnounceEvent + 'extension:module:de-announced': ExtensionModuleAnnounceEvent & { reason?: string } + 'extension:kit:announce': ExtensionKitAnnounceEvent + 'extension:kit:announced': ExtensionKitAnnounceEvent + 'extension:kit:de-announced': ExtensionKitAnnounceEvent & { reason?: string } + 'module:authenticate': ModuleAuthenticateEvent 'module:authenticated': ModuleAuthenticatedEvent /** diff --git a/packages/plugin-protocol/vitest.config.ts b/packages/plugin-protocol/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/plugin-protocol/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/plugin-sdk-tamagotchi/package.json b/packages/plugin-sdk-tamagotchi/package.json index ec059e32a..8ecb2c004 100644 --- a/packages/plugin-sdk-tamagotchi/package.json +++ b/packages/plugin-sdk-tamagotchi/package.json @@ -28,6 +28,14 @@ "types": "./dist/gamelet/index.d.mts", "default": "./dist/gamelet/index.mjs" }, + "./kits/gamelet": { + "types": "./dist/kits/gamelet/index.d.mts", + "default": "./dist/kits/gamelet/index.mjs" + }, + "./kits/tool": { + "types": "./dist/kits/tool/index.d.mts", + "default": "./dist/kits/tool/index.mjs" + }, "./tools": { "types": "./dist/tools/index.d.mts", "default": "./dist/tools/index.mjs" @@ -48,6 +56,7 @@ "dependencies": { "@moeru/eventa": "catalog:", "@proj-airi/plugin-sdk": "workspace:*", + "nanoid": "catalog:", "valibot": "catalog:", "xsschema": "catalog:" } diff --git a/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts b/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts index 39f02a933..917e2ada1 100644 --- a/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts +++ b/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts @@ -1,184 +1,92 @@ -import type { ContextInit } from '@proj-airi/plugin-sdk' +import type { KitClientRuntime } from '@proj-airi/plugin-sdk' import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' -/** - * Describes a widget hint contributed by a gamelet to the tamagotchi host. - * - * Use when: - * - A gamelet should expose one or more mountable widget surfaces - * - * Expects: - * - `id` is stable within the gamelet - * - * Returns: - * - A serializable host hint for widget registration - */ -export interface GameletWidgetDefinition { - id: string - kind: string +import { defineKit } from '@proj-airi/plugin-sdk' + +export interface GameletKitClient { + iframe: (input: { assetPath?: string, src?: string, sandbox?: string }) => HostDataRecord + mount: (definition: { + /** Fully qualified host binding id used as the host-side module id. */ + bindingId?: string + title: string + ui: HostDataRecord + init?: HostDataRecord + }) => Promise + orchestration?: GameletKitRuntime['gamelets'] } -/** - * Describes host-managed configuration defaults declared by a gamelet. - * - * Use when: - * - A gamelet wants the host to persist validated defaults - * - * Expects: - * - `defaults` is JSON-compatible - * - * Returns: - * - The configuration declaration stored in the gamelet module config - */ -export interface GameletConfigDefinition { - defaults?: TDefaults -} - -/** - * Describes the friendly tamagotchi authoring shape for a gamelet. - * - * Use when: - * - A plugin wants to register one UI-driven gamelet without raw kit/module calls - * - * Expects: - * - `entrypoint` points at the plugin-provided UI asset entry - * - * Returns: - * - A declarative gamelet definition consumed by {@link defineGamelet} - */ -export interface GameletDefinition { - id: string - title: string - entrypoint: string - widgets?: GameletWidgetDefinition[] - config?: GameletConfigDefinition -} - -/** - * Represents one registered tamagotchi gamelet. - * - * Use when: - * - Tools or plugin bootstrap code need to check whether host registration succeeded - * - * Expects: - * - Returned values come from a previously completed {@link defineGamelet} call - * - * Returns: - * - A minimal handle that keeps host lifecycle concerns internal - */ -export interface DefinedGamelet { - id: string - isSupported: () => Promise -} - -/** - * Normalizes one author-facing gamelet widget into host-safe binding config data. - * - * Before: - * - `{ id: 'main-board', kind: 'primary' }` - * - * After: - * - `{ id: 'main-board', kind: 'primary' }` - */ -function createWidgetHintRecord(definition: GameletWidgetDefinition): HostDataRecord { - return { - id: definition.id, - kind: definition.kind, +export interface GameletKitRuntime extends KitClientRuntime { + bindings?: { + bind: (input: { + moduleId: string + kitId: string + kitModuleType: string + runtime?: string + config: HostDataRecord + }) => Promise | unknown + } + gamelets?: { + open: (bindingId: string, payload?: HostDataRecord) => Promise | void + configure: (bindingId: string, payload: HostDataRecord) => Promise | void + request: ( + bindingId: string, + payload: HostDataRecord, + options?: { timeoutMs?: number }, + ) => Promise | TResponse + close: (bindingId: string) => Promise | void + isOpen: (bindingId: string) => Promise | boolean } } /** - * Normalizes one gamelet definition into binding config stored in `kit.gamelet`. + * Derives the host binding id used by the gamelet kit client. * * Before: - * - Friendly authoring fields that may include optional properties and typed helper objects + * - `{ sessionId: "session-1", moduleId: undefined }` * * After: - * - A plain `HostDataRecord` with only host-safe values and no `undefined` properties + * - `"session-1:gamelet"` */ -function buildModuleConfig(definition: GameletDefinition): HostDataRecord { - return { - title: definition.title, - entrypoint: definition.entrypoint, - widgets: (definition.widgets ?? []).map(createWidgetHintRecord), - widget: { - mount: 'iframe', - iframe: { - assetPath: definition.entrypoint, - sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', - }, - windowSize: { - width: 980, - height: 840, - minWidth: 640, - minHeight: 640, - }, - }, - ...(definition.config - ? { - config: { - defaults: definition.config.defaults ?? {}, +function createGameletBindingId(runtime: KitClientRuntime): string { + return `${runtime.moduleId ?? runtime.sessionId}:gamelet` +} + +export const gameletKit = defineKit({ + id: 'kit.gamelet', + version: '1.0.0', + allowedExposePolicies: ['local-only', 'remote-observable'], + defaultExposePolicy: 'local-only', + createClient(runtime) { + const gameletRuntime = runtime as GameletKitRuntime + return { + iframe(input) { + return { + mount: 'iframe', + iframe: { + ...input, + sandbox: input.sandbox ?? 'allow-scripts allow-same-origin allow-forms allow-popups', }, } - : {}), - } -} - -/** - * Registers a tamagotchi gamelet through the low-level kit/binding APIs. - * - * Use when: - * - A plugin targets stage-tamagotchi and wants one-step gamelet registration - * - * Expects: - * - The host exposes the `kit.gamelet` kit through `ctx.apis.kits` - * - * Returns: - * - A handle that reports whether the host supports the gamelet kit - */ -export async function defineGamelet( - ctx: Pick, - definition: GameletDefinition, -): Promise { - const kits = await ctx.apis.kits.list() - const supported = kits.some(kit => kit.kitId === 'kit.gamelet') - - if (!supported) { - return { - id: definition.id, - async isSupported() { - return false }, + async mount(definition) { + if (!gameletRuntime.bindings) { + throw new Error('gameletKit requires a host binding runtime.') + } + + return await gameletRuntime.bindings.bind({ + moduleId: definition.bindingId ?? createGameletBindingId(runtime), + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { + title: definition.title, + widget: definition.ui, + config: { + init: definition.init ?? {}, + }, + }, + }) + }, + orchestration: gameletRuntime.gamelets, } - } - - const existingModules = await ctx.apis.bindings.list() - const existingModule = existingModules.find(module => module.moduleId === definition.id) - const config = buildModuleConfig(definition) - - if (!existingModule) { - await ctx.apis.bindings.announce({ - moduleId: definition.id, - kitId: 'kit.gamelet', - kitModuleType: 'gamelet', - config, - }) - } - else { - await ctx.apis.bindings.update({ - moduleId: definition.id, - config, - }) - } - - await ctx.apis.bindings.activate({ - moduleId: definition.id, - }) - - return { - id: definition.id, - async isSupported() { - return true - }, - } -} + }, +}) diff --git a/packages/plugin-sdk-tamagotchi/src/index.test.ts b/packages/plugin-sdk-tamagotchi/src/index.test.ts index d2d2f7c96..ddc811b52 100644 --- a/packages/plugin-sdk-tamagotchi/src/index.test.ts +++ b/packages/plugin-sdk-tamagotchi/src/index.test.ts @@ -1,101 +1,414 @@ -import type { ContextInit } from '@proj-airi/plugin-sdk' +import type { + ExtensionModuleRef, + KitAvailability, + KitClientRuntime, + KitRef, + KitUseResult, +} from '@proj-airi/plugin-sdk' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' -import type { TamagotchiToolContext } from './index' +import type { ToolKitRuntime } from './tools' +import { DisposableStore } from '@proj-airi/plugin-sdk' import { object, optional, string } from 'valibot' import { describe, expect, it, vi } from 'vitest' -import { defineGamelet, defineToolset } from './index' +import { gameletKit, TamagotchiToolRegistry, toolKit } from './index' +import { createGamelet } from './kits/gamelet' +import { registerTools } from './kits/tool' + +type ToolRuntimeServices = NonNullable +type GameletOrchestrationRuntime = NonNullable['orchestration']> + +function createGameletRuntime(input: { + extensionId: string + sessionId: string + moduleId?: string + bind: (input: unknown) => Promise | unknown + gamelets?: GameletOrchestrationRuntime +}): KitClientRuntime & { + bindings: { + bind: (input: unknown) => Promise | unknown + } + gamelets?: GameletOrchestrationRuntime +} { + return { + extensionId: input.extensionId, + sessionId: input.sessionId, + moduleId: input.moduleId, + subscriptions: new DisposableStore(), + bindings: { + bind: input.bind, + }, + gamelets: input.gamelets, + } +} + +function createToolRuntime(input: { + extensionId: string + sessionId: string + moduleId?: string + register: ToolRuntimeServices['register'] + registerToolsetPrompt: ToolRuntimeServices['registerToolsetPrompt'] +}): ToolKitRuntime { + return { + extensionId: input.extensionId, + sessionId: input.sessionId, + moduleId: input.moduleId, + subscriptions: new DisposableStore(), + tools: { + register: input.register, + registerToolsetPrompt: input.registerToolsetPrompt, + }, + } +} + +function createGameletModuleRef(input: { + id: string + extensionId: string + sessionId: string + bind: (input: unknown) => Promise | unknown + gamelets?: GameletOrchestrationRuntime +}): { module: ExtensionModuleRef, useKit: ReturnType } { + const useKit = vi.fn() + + const module: ExtensionModuleRef = { + id: input.id, + kits: { + async use(kit: KitRef): Promise { + useKit(kit) + if (kit !== gameletKit) { + throw new Error(`Unexpected kit requested: ${kit.id}`) + } + + return gameletKit.createClient(createGameletRuntime({ + extensionId: input.extensionId, + sessionId: input.sessionId, + moduleId: input.id, + bind: input.bind, + gamelets: input.gamelets, + })) as TClient + }, + async tryUse(kit: KitRef): Promise> { + return { + ok: false, + reason: 'missing-kit', + error: new Error(`Unused test kit lookup: ${kit.id}`), + } + }, + watch( + _kit: KitRef, + _callback: (availability: KitAvailability) => void | Promise, + ) { + return { dispose: vi.fn() } + }, + }, + subscriptions: new DisposableStore(), + dispose: vi.fn(async () => {}), + } + + return { module, useKit } +} + +function createToolModuleRef(input: { + id: string + extensionId: string + sessionId: string + register: ToolRuntimeServices['register'] + registerToolsetPrompt: ToolRuntimeServices['registerToolsetPrompt'] +}): { module: ExtensionModuleRef, useKit: ReturnType } { + const useKit = vi.fn() + + const module: ExtensionModuleRef = { + id: input.id, + kits: { + async use(kit: KitRef): Promise { + useKit(kit) + if (kit !== toolKit) { + throw new Error(`Unexpected kit requested: ${kit.id}`) + } + + return toolKit.createClient(createToolRuntime({ + extensionId: input.extensionId, + sessionId: input.sessionId, + moduleId: input.id, + register: input.register, + registerToolsetPrompt: input.registerToolsetPrompt, + })) as TClient + }, + async tryUse(kit: KitRef): Promise> { + return { + ok: false, + reason: 'missing-kit', + error: new Error(`Unused test kit lookup: ${kit.id}`), + } + }, + watch( + _kit: KitRef, + _callback: (availability: KitAvailability) => void | Promise, + ) { + return { dispose: vi.fn() } + }, + }, + subscriptions: new DisposableStore(), + dispose: vi.fn(async () => {}), + } + + return { module, useKit } +} describe('plugin-sdk-tamagotchi', () => { - /** - * @example - * expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' })) - * expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) })) - */ - it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => { - const registerBinding = vi.fn() - const registerTool = vi.fn() - const openGamelet = vi.fn() - const configureGamelet = vi.fn() - const closeGamelet = vi.fn() - const isGameletOpen = vi.fn(() => true) - - const ctx: Pick & TamagotchiToolContext = { - apis: { - gamelets: { - open: openGamelet, - configure: configureGamelet, - request: vi.fn(async () => ({})), - close: closeGamelet, - isOpen: isGameletOpen, - }, - tools: { - register: registerTool, - registerToolsetPrompt: vi.fn(), - }, - kits: { - list: async () => [ - { - kitId: 'kit.gamelet', - version: '1.0.0', - runtimes: ['electron'], - capabilities: [], - }, - ], - getCapabilities: async () => [ - { - key: 'kit.gamelet.runtime', - actions: ['announce', 'activate', 'update'], - }, - ], - }, - bindings: { - list: async () => [], - announce: registerBinding, - update: registerBinding, - activate: registerBinding, - withdraw: registerBinding, - }, - providers: { - listProviders: async () => [], - }, + it('exposes gameletKit as a module-scoped kit client', async () => { + const bindings: unknown[] = [] + const client = gameletKit.createClient(createGameletRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + bind: async (input: unknown) => { + bindings.push(input) + return { moduleId: 'chess:gamelet', state: 'active' } }, - } + })) - const gamelet = await defineGamelet(ctx, { - id: 'chess', + await client.mount({ title: 'Chess', - entrypoint: './ui/index.html', - widgets: [ - { - id: 'main-board', - kind: 'primary', - }, - ], + ui: client.iframe({ assetPath: 'ui/index.html' }), + init: { airiSide: 'black' }, }) - await defineToolset(ctx, { - id: 'chess-tools', + expect(bindings).toHaveLength(1) + expect(bindings[0]).toMatchObject({ + moduleId: 'chess:gamelet', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + }) + }) + + /** + * @example + * expect(bindings[0]).toMatchObject({ moduleId: 'session-1:gamelet' }) + */ + it('derives a stable gameletKit binding id for extension-scoped clients', async () => { + const bindings: unknown[] = [] + const client = gameletKit.createClient(createGameletRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + bind: async (input: unknown) => { + bindings.push(input) + return { moduleId: 'session-1:gamelet', state: 'active' } + }, + })) + + await client.mount({ + title: 'Chess', + ui: client.iframe({ assetPath: 'ui/index.html' }), + }) + + expect(bindings).toHaveLength(1) + expect(bindings[0]).toMatchObject({ + moduleId: 'session-1:gamelet', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + }) + }) + + /** + * @example + * expect(open).toHaveBeenCalledWith('chess:board', { mode: 'new' }) + * expect(isOpen).toHaveBeenCalledWith('chess:board') + */ + it('routes createGamelet handle orchestration calls through the host gamelet runtime', async () => { + const open = vi.fn(async (_bindingId: string, _payload?: HostDataRecord) => {}) + const configure = vi.fn(async (_bindingId: string, _payload: HostDataRecord) => {}) + const requestCalls: [string, HostDataRecord, { timeoutMs?: number } | undefined][] = [] + const request: GameletOrchestrationRuntime['request'] = async ( + bindingId: string, + payload: HostDataRecord, + options?: { timeoutMs?: number }, + ): Promise => { + requestCalls.push([bindingId, payload, options]) + return { ok: true } as TResponse + } + const close = vi.fn(async (_bindingId: string) => {}) + const isOpen = vi.fn(async (_bindingId: string) => true) + const { module } = createGameletModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + bind: async () => ({ moduleId: 'chess:board', state: 'active' }), + gamelets: { + open, + configure, + request, + close, + isOpen, + }, + }) + + const handle = await createGamelet(module, { + id: 'board', + title: 'Chess Board', + indexPath: 'ui/index.html', + }) + + await handle.open({ mode: 'new' }) + await handle.configure({ airiSide: 'black' }) + await handle.request({ action: 'snapshot' }) + await handle.close() + await expect(handle.isOpen()).resolves.toBe(true) + + expect(open).toHaveBeenCalledWith('chess:board', { mode: 'new' }) + expect(configure).toHaveBeenCalledWith('chess:board', { airiSide: 'black' }) + expect(requestCalls).toEqual([['chess:board', { action: 'snapshot' }, undefined]]) + expect(close).toHaveBeenCalledWith('chess:board') + expect(isOpen).toHaveBeenCalledWith('chess:board') + }) + + /** + * @example + * await expect(handle.open()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + */ + it('reports a clear error when createGamelet orchestration methods run without a host runtime', async () => { + const { module } = createGameletModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + bind: async () => ({ moduleId: 'chess:board', state: 'active' }), + }) + + const handle = await createGamelet(module, { + id: 'board', + title: 'Chess Board', + indexPath: 'ui/index.html', + }) + + await expect(handle.open()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + await expect(handle.configure({ airiSide: 'black' })).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + await expect(handle.request({ action: 'snapshot' })).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + await expect(handle.close()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + await expect(handle.isOpen()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.') + await expect(module.subscriptions.dispose()).resolves.toBeUndefined() + }) + + /** + * @example + * await module.subscriptions.dispose() + * expect(close).toHaveBeenCalledWith('chess:board') + */ + it('registers gamelet close cleanup with the module subscription scope', async () => { + const close = vi.fn(async (_bindingId: string) => {}) + const { module } = createGameletModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + bind: async () => ({ moduleId: 'chess:board', state: 'active' }), + gamelets: { + open: vi.fn(), + configure: vi.fn(), + request: vi.fn(), + close, + isOpen: vi.fn(), + }, + }) + + await createGamelet(module, { + id: 'board', + title: 'Chess Board', + indexPath: 'ui/index.html', + }) + await module.subscriptions.dispose() + + expect(close).toHaveBeenCalledWith('chess:board') + }) + + /** + * @example + * expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.objectContaining({ id: 'play_chess' }) })) + * expect(registerPrompt).toHaveBeenCalledWith(expect.objectContaining({ id: 'chess-tools' })) + */ + it('exposes toolKit as a module-scoped kit client without a gamelet runtime', async () => { + const registerTool = vi.fn() + const registerPrompt = vi.fn() + + const client = toolKit.createClient(createToolRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + register: registerTool, + registerToolsetPrompt: registerPrompt, + })) + + await client.registerToolsetPrompt({ + id: 'chess-toolset', prompt: { id: 'airi-plugin-game-chess.prompt', title: 'Chess Plugin Guidance', content: 'Do not pass fen or pgn when mode is "new".', }, + }) + await client.registerTool({ + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + inputSchema: object({}), + execute: async () => ({ ok: true }), + }) + + expect(registerPrompt).toHaveBeenCalledWith({ + id: 'chess-toolset', + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Do not pass fen or pgn when mode is "new".', + }, + }) + expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ + tool: expect.objectContaining({ + id: 'play_chess', + }), + })) + + await expect(registerTool.mock.calls[0]?.[0].execute({})).resolves.toEqual({ ok: true }) + }) + + /** + * @example + * expect(useKit).toHaveBeenCalledWith(toolKit) + * expect(registerToolsetPrompt).toHaveBeenCalledBefore(registerTool) + */ + it('registers a toolset prompt before module-scoped tools through the tool helper', async () => { + const registerTool = vi.fn() + const registerToolsetPrompt = vi.fn() + const { module, useKit } = createToolModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + register: registerTool, + registerToolsetPrompt, + }) + + await registerTools(module, { + prompt: { + id: 'chess-tools', + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Do not pass fen or pgn when mode is "new".', + }, + }, tools: [ { id: 'play_chess', title: 'Play Chess', description: 'Open chess.', - inputSchema: object({ - opening: optional(string()), - }), + inputSchema: object({}), execute: async () => ({ ok: true }), }, ], }) - expect(ctx.apis.tools.registerToolsetPrompt).toHaveBeenCalledWith({ + expect(useKit).toHaveBeenCalledWith(toolKit) + expect(registerToolsetPrompt).toHaveBeenCalledWith({ id: 'chess-tools', prompt: { id: 'airi-plugin-game-chess.prompt', @@ -103,39 +416,201 @@ describe('plugin-sdk-tamagotchi', () => { content: 'Do not pass fen or pgn when mode is "new".', }, }) - expect(gamelet).toBeDefined() - expect(registerBinding).toHaveBeenCalledWith({ + expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ + tool: expect.objectContaining({ + id: 'play_chess', + }), + })) + + expect(registerToolsetPrompt.mock.invocationCallOrder[0]).toBeLessThan(registerTool.mock.invocationCallOrder[0]) + }) + + /** + * @example + * expect(registerToolsetPrompt).toHaveBeenCalledWith({ id: 'airi-plugin-game-chess.prompt', prompt: expect.any(Object) }) + * expect(registerTool).not.toHaveBeenCalled() + */ + it('normalizes shorthand toolset prompts before registration', async () => { + const registerTool = vi.fn() + const registerToolsetPrompt = vi.fn() + const { module } = createToolModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + register: registerTool, + registerToolsetPrompt, + }) + + await registerTools(module, { + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Start chess directly.', + }, + tools: [], + }) + + expect(registerToolsetPrompt).toHaveBeenCalledWith({ + id: 'airi-plugin-game-chess.prompt', + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Start chess directly.', + }, + }) + expect(registerTool).not.toHaveBeenCalled() + }) + + it('stores, invokes, and removes module-scoped Tamagotchi tools', async () => { + const registry = new TamagotchiToolRegistry() + const execute = vi.fn(async () => ({ ok: true })) + + registry.register({ + ownerSessionId: 'session-1', + ownerPluginId: 'airi-extension-chess', + ownerModuleId: 'chess', + tool: { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['chess'], + }, + parameters: { + type: 'object', + properties: {}, + }, + }, + execute, + }) + registry.registerToolsetPrompt({ + ownerSessionId: 'session-1', + ownerPluginId: 'airi-extension-chess', + ownerModuleId: 'chess', + toolset: { + id: 'chess-tools', + prompt: { + id: 'airi-plugin-game-chess.prompt', + content: 'Prefer legal chess moves.', + }, + }, + }) + + await expect(registry.listAvailableDescriptors()).resolves.toEqual([{ + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['chess'], + }, + }]) + await expect(registry.listSerializedXsaiTools()).resolves.toEqual({ + prompts: [{ + ownerPluginId: 'airi-extension-chess', + id: 'chess-tools', + prompt: { + id: 'airi-plugin-game-chess.prompt', + content: 'Prefer legal chess moves.', + }, + }], + tools: [{ + ownerPluginId: 'airi-extension-chess', + name: 'play_chess', + description: 'Open chess.', + parameters: { + type: 'object', + properties: {}, + }, + }], + }) + await expect(registry.invoke('airi-extension-chess', 'play_chess', { move: 'e4' })).resolves.toEqual({ ok: true }) + expect(execute).toHaveBeenCalledWith({ move: 'e4' }) + + registry.unregisterOwnerScope('session-1', 'chess') + + await expect(registry.listSerializedXsaiTools()).resolves.toEqual({ + prompts: [], + tools: [], + }) + await expect(registry.invoke('airi-extension-chess', 'play_chess', {})).rejects.toThrow( + 'Tamagotchi extension tool not found: airi-extension-chess:play_chess', + ) + }) + + /** + * @example + * expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' })) + * expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) })) + */ + it('allows gamelet and tool kits to be composed without coupling tool registration to gamelets', async () => { + const registerBinding = vi.fn() + const registerTool = vi.fn() + const registerToolsetPrompt = vi.fn() + const gamelets = gameletKit.createClient(createGameletRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', moduleId: 'chess', + bind: registerBinding, + })) + const tools = toolKit.createClient(createToolRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + register: registerTool, + registerToolsetPrompt, + })) + + await gamelets.mount({ + title: 'Chess', + ui: gamelets.iframe({ assetPath: './ui/index.html' }), + }) + + await tools.registerToolsetPrompt({ + id: 'chess-tools', + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Do not pass fen or pgn when mode is "new".', + }, + }) + await tools.registerTool({ + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + inputSchema: object({ + opening: optional(string()), + }), + execute: async () => ({ ok: true }), + }) + + expect(registerToolsetPrompt).toHaveBeenCalledWith({ + id: 'chess-tools', + prompt: { + id: 'airi-plugin-game-chess.prompt', + title: 'Chess Plugin Guidance', + content: 'Do not pass fen or pgn when mode is "new".', + }, + }) + expect(registerBinding).toHaveBeenCalledWith({ + moduleId: 'chess:gamelet', kitId: 'kit.gamelet', kitModuleType: 'gamelet', config: { title: 'Chess', - entrypoint: './ui/index.html', - widgets: [ - { - id: 'main-board', - kind: 'primary', - }, - ], widget: { mount: 'iframe', iframe: { assetPath: './ui/index.html', sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }, - windowSize: { - width: 980, - height: 840, - minWidth: 640, - minHeight: 640, - }, + }, + config: { + init: {}, }, }, }) - expect(registerBinding).toHaveBeenCalledWith({ - moduleId: 'chess', - }) - expect(registerTool).toHaveBeenCalled() expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.objectContaining({ id: 'play_chess', @@ -151,12 +626,7 @@ describe('plugin-sdk-tamagotchi', () => { }), })) - await registerTool.mock.calls[0]?.[0].execute({}) - - expect(openGamelet).not.toHaveBeenCalled() - expect(configureGamelet).not.toHaveBeenCalled() - expect(closeGamelet).not.toHaveBeenCalled() - expect(isGameletOpen).not.toHaveBeenCalled() + await expect(registerTool.mock.calls[0]?.[0].execute({})).resolves.toEqual({ ok: true }) }) /** @@ -164,49 +634,42 @@ describe('plugin-sdk-tamagotchi', () => { * expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' }) * expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' }) */ - it('passes host-backed gamelet operations through defineToolset execution context', async () => { + it('lets extension authors compose gamelet handles inside tool execution closures', async () => { const registerTool = vi.fn() const openGamelet = vi.fn() const configureGamelet = vi.fn() const closeGamelet = vi.fn() - const isGameletOpen = vi.fn(() => true) + const isGameletOpen = vi.fn<(id: string) => boolean>(() => true) - const ctx: TamagotchiToolContext = { - apis: { - gamelets: { - open: openGamelet, - configure: configureGamelet, - request: vi.fn(async () => ({ ready: true })), - close: closeGamelet, - isOpen: isGameletOpen, - }, - tools: { - register: registerTool, - registerToolsetPrompt: vi.fn(), - }, - }, + const gamelets = { + open: openGamelet, + configure: configureGamelet, + request: vi.fn<(id: string, payload: Record) => Promise>>(async () => ({ ready: true })), + close: closeGamelet, + isOpen: isGameletOpen, } + const tools = toolKit.createClient(createToolRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + register: registerTool, + registerToolsetPrompt: vi.fn(), + })) - await defineToolset(ctx, { - tools: [ - { - id: 'drive_chess', - title: 'Drive Chess', - description: 'Drive a host-backed chess gamelet.', - inputSchema: object({}), - async isAvailable(context) { - return await context.gamelets.isOpen('chess') - }, - async execute(_input, context) { - await context.gamelets.open('chess', { opening: 'sicilian' }) - await context.gamelets.configure('chess', { side: 'black' }) - await context.gamelets.request('chess', { action: 'snapshot' }) - await context.gamelets.close('chess') + await tools.registerTool({ + id: 'drive_chess', + title: 'Drive Chess', + description: 'Drive a host-backed chess gamelet.', + inputSchema: object({}), + isAvailable: async () => await gamelets.isOpen('chess'), + async execute() { + await gamelets.open('chess', { opening: 'sicilian' }) + await gamelets.configure('chess', { side: 'black' }) + await gamelets.request('chess', { action: 'snapshot' }) + await gamelets.close('chess') - return { ok: true } - }, - }, - ], + return { ok: true } + }, }) const registration = registerTool.mock.calls[0]?.[0] @@ -218,7 +681,7 @@ describe('plugin-sdk-tamagotchi', () => { expect(registration.availability).toBeTypeOf('function') expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' }) expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' }) - expect(ctx.apis.gamelets.request).toHaveBeenCalledWith('chess', { action: 'snapshot' }) + expect(gamelets.request).toHaveBeenCalledWith('chess', { action: 'snapshot' }) expect(closeGamelet).toHaveBeenCalledWith('chess') }) @@ -228,35 +691,23 @@ describe('plugin-sdk-tamagotchi', () => { */ it('serializes optional tool fields as required nullable properties for strict OpenAI-compatible schemas', async () => { const registerTool = vi.fn() - const ctx: TamagotchiToolContext = { - apis: { - gamelets: { - open: vi.fn(), - configure: vi.fn(), - request: vi.fn(async () => ({})), - close: vi.fn(), - isOpen: vi.fn(() => true), - }, - tools: { - register: registerTool, - registerToolsetPrompt: vi.fn(), - }, - }, - } + const tools = toolKit.createClient(createToolRuntime({ + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + moduleId: 'chess', + register: registerTool, + registerToolsetPrompt: vi.fn(), + })) - await defineToolset(ctx, { - tools: [ - { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - inputSchema: object({ - mode: string(), - opening: optional(string()), - }), - execute: async () => ({ ok: true }), - }, - ], + await tools.registerTool({ + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + inputSchema: object({ + mode: string(), + opening: optional(string()), + }), + execute: async () => ({ ok: true }), }) const parameters = registerTool.mock.calls[0]?.[0].tool.parameters @@ -267,31 +718,85 @@ describe('plugin-sdk-tamagotchi', () => { /** * @example - * await expect(defineToolset({ apis: { tools: { register: registerTool } } } as never, options)).rejects.toThrow(/gamelet API/i) + * expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ moduleId: 'chess:board' })) + * expect(gamelet.bindingId).toBe('chess:board') */ - it('fails with a clear error when the tamagotchi gamelet API is not available', async () => { - const registerTool = vi.fn() + it('creates a gamelet helper with an explicit module-scoped binding id', async () => { + const registerBinding = vi.fn() + const { module, useKit } = createGameletModuleRef({ + id: 'chess', + extensionId: 'airi-extension-chess', + sessionId: 'session-1', + bind: registerBinding, + }) - await expect(defineToolset({ - apis: { - tools: { - register: registerTool, - }, - }, - } as never, { - tools: [ - { - id: 'drive_chess', - title: 'Drive Chess', - description: 'Drive a host-backed chess gamelet.', - inputSchema: object({}), - async execute() { - return { ok: true } + const gamelet = await createGamelet(module, { + id: 'board', + title: 'Chess', + indexPath: './ui/index.html', + init: { airiSide: 'black' }, + }) + + expect(gamelet.id).toBe('board') + expect(gamelet.bindingId).toBe('chess:board') + expect(useKit).toHaveBeenCalledWith(gameletKit) + expect(registerBinding).toHaveBeenCalledWith({ + moduleId: 'chess:board', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { + title: 'Chess', + widget: { + mount: 'iframe', + iframe: { + assetPath: './ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }, }, - ], - })).rejects.toThrow(/gamelet API/i) + config: { + init: { airiSide: 'black' }, + }, + }, + }) + }) - expect(registerTool).not.toHaveBeenCalled() + /** + * @example + * expect(gamelet.bindingId).toBe(`feature:${gamelet.id}`) + */ + it('creates a gamelet helper with a generated id when omitted', async () => { + const registerBinding = vi.fn() + const { module } = createGameletModuleRef({ + id: 'feature', + extensionId: 'airi-extension-feature', + sessionId: 'session-1', + bind: registerBinding, + }) + + const gamelet = await createGamelet(module, { + title: 'Feature', + indexPath: './ui/index.html', + }) + + expect(gamelet.id).not.toBe('') + expect(gamelet.bindingId).toBe(`feature:${gamelet.id}`) + expect(registerBinding).toHaveBeenCalledWith({ + moduleId: gamelet.bindingId, + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { + title: 'Feature', + widget: { + mount: 'iframe', + iframe: { + assetPath: './ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + }, + config: { + init: {}, + }, + }, + }) }) }) diff --git a/packages/plugin-sdk-tamagotchi/src/kits/gamelet/index.ts b/packages/plugin-sdk-tamagotchi/src/kits/gamelet/index.ts new file mode 100644 index 000000000..147b203bc --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/kits/gamelet/index.ts @@ -0,0 +1,132 @@ +import type { ExtensionModuleRef } from '@proj-airi/plugin-sdk' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' + +import { nanoid } from 'nanoid/non-secure' + +import { gameletKit } from '../../gamelet' + +const GAMELET_RUNTIME_UNAVAILABLE_MESSAGE = 'gameletKit requires a host gamelet orchestration runtime.' + +/** + * Options used to declare and mount one Tamagotchi gamelet UI contribution. + * + * @param TInit Initial host-safe configuration passed to the mounted gamelet. + */ +export interface CreateGameletOptions { + /** Stable gamelet id within the extension module. Generated when omitted. */ + id?: string + /** Human-readable title shown by the host around the gamelet surface. */ + title: string + /** Plugin asset path for the iframe HTML entrypoint. */ + indexPath: string + /** Initial host-safe configuration delivered to the iframe widget. */ + init?: TInit + /** iframe sandbox policy. Defaults to the low-level gamelet kit sandbox. */ + sandbox?: string + /** Development server URL used by hosts that can load an iframe from a live dev server. */ + devServerUrl?: string +} + +/** + * Runtime handle returned after a gamelet is mounted. + * + * @param TInit Initial host-safe configuration type associated with the gamelet. + */ +export interface GameletHandle { + /** Stable gamelet id within the extension module. */ + id: string + /** Fully qualified host binding id, formatted as `:`. */ + bindingId: string + /** Initial host-safe configuration delivered to the iframe widget. */ + init?: TInit + /** Opens the gamelet through the host orchestration runtime. */ + open: (payload?: HostDataRecord) => Promise + /** Reconfigures the gamelet through the host orchestration runtime. */ + configure: (payload: HostDataRecord) => Promise + /** Sends a request to the gamelet through the host orchestration runtime. */ + request: (payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise + /** Closes the gamelet through the host orchestration runtime. */ + close: () => Promise + /** Reports whether the gamelet is open through the host orchestration runtime. */ + isOpen: () => Promise +} + +/** + * Creates and mounts a module-scoped Tamagotchi gamelet. + * + * Use when: + * - Extension authors need an iframe-backed gamelet with a stable module binding id + * - Tool handlers need to retain a handle for future gamelet orchestration calls + * + * Expects: + * - `module` is an {@link ExtensionModuleRef} with access to {@link gameletKit} + * - `options.indexPath` points at the gamelet iframe HTML asset + * + * Returns: + * - A handle containing the local id, host binding id, initial config, and orchestration methods + */ +export async function createGamelet( + module: ExtensionModuleRef, + options: CreateGameletOptions, +): Promise> { + const id = options.id ?? nanoid() + const bindingId = `${module.id}:${id}` + const gamelets = await module.kits.use(gameletKit) + + await gamelets.mount({ + bindingId, + title: options.title, + ui: gamelets.iframe({ + assetPath: options.devServerUrl === undefined ? options.indexPath : undefined, + src: options.devServerUrl, + sandbox: options.sandbox, + }), + init: options.init, + }) + + const handle: GameletHandle = { + id, + bindingId, + open: async (payload?: HostDataRecord) => { + await requireOrchestration(gamelets).open(bindingId, payload) + }, + configure: async (payload: HostDataRecord) => { + await requireOrchestration(gamelets).configure(bindingId, payload) + }, + request: async ( + payload: HostDataRecord, + options?: { timeoutMs?: number }, + ): Promise => { + return await requireOrchestration(gamelets).request(bindingId, payload, options) + }, + close: async () => { + await requireOrchestration(gamelets).close(bindingId) + }, + isOpen: async () => await requireOrchestration(gamelets).isOpen(bindingId), + } + + module.subscriptions.add({ + async dispose() { + await gamelets.orchestration?.close(bindingId) + }, + }) + + if (options.init === undefined) { + return handle + } + + return { + ...handle, + init: options.init, + } +} + +export { gameletKit } + +function requireOrchestration(gamelets: Awaited>): NonNullable { + if (!gamelets.orchestration) { + throw new Error(GAMELET_RUNTIME_UNAVAILABLE_MESSAGE) + } + + return gamelets.orchestration +} diff --git a/packages/plugin-sdk-tamagotchi/src/kits/tool/index.ts b/packages/plugin-sdk-tamagotchi/src/kits/tool/index.ts new file mode 100644 index 000000000..4c8182378 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/kits/tool/index.ts @@ -0,0 +1,78 @@ +import type { ExtensionModuleRef } from '@proj-airi/plugin-sdk' + +import type { + PluginToolDefinition, + PluginToolsetPromptRegistration, + ToolsetPromptManifest, +} from '../../tools' + +import { toolKit } from '../../tools' + +/** + * Options used to register one Tamagotchi module toolset. + * + * @param TInputSchema Schema implementation accepted by each tool definition. + */ +export interface RegisterToolsOptions { + /** Optional shared toolset prompt registered before any tools. */ + prompt?: PluginToolsetPromptRegistration | ToolsetPromptManifest + /** Tool declarations registered in order through {@link toolKit}. */ + tools: Array> +} + +/** + * Normalizes shorthand toolset prompts into host registration input. + * + * Before: + * - `{ id: "chess.prompt", content: "Prefer legal chess moves." }` + * + * After: + * - `{ id: "chess.prompt", prompt: { id: "chess.prompt", content: "Prefer legal chess moves." } }` + */ +export function normalizePrompt( + prompt: PluginToolsetPromptRegistration | ToolsetPromptManifest, +): PluginToolsetPromptRegistration { + if ('prompt' in prompt) { + return prompt + } + + return { + id: prompt.id, + prompt, + } +} + +/** + * Registers a module-scoped Tamagotchi toolset through the host tool kit. + * + * Use when: + * - Extension modules need one helper to register a shared prompt and tools + * - Tool authors want prompt registration to happen before tool registration + * + * Expects: + * - `module` has access to {@link toolKit} + * - `options.tools` contains schema-backed plugin tool definitions + * + * Returns: + * - Resolves after the optional prompt and all tools are registered + */ +export async function registerTools( + module: ExtensionModuleRef, + options: RegisterToolsOptions, +): Promise { + const tools = await module.kits.use(toolKit) + + if (options.prompt) { + await tools.registerToolsetPrompt(normalizePrompt(options.prompt)) + } + + for (const tool of options.tools) { + await tools.registerTool(tool) + } +} + +export { + type PluginToolDefinition, + type PluginToolsetPromptRegistration, + toolKit, +} diff --git a/packages/plugin-sdk-tamagotchi/src/tools/index.ts b/packages/plugin-sdk-tamagotchi/src/tools/index.ts index 646932459..758569724 100644 --- a/packages/plugin-sdk-tamagotchi/src/tools/index.ts +++ b/packages/plugin-sdk-tamagotchi/src/tools/index.ts @@ -1,70 +1,30 @@ -import type { ContextInit } from '@proj-airi/plugin-sdk' -import type { HostDataRecord, ToolsetPromptManifest } from '@proj-airi/plugin-sdk/plugin-host' +import type { KitClientRuntime } from '@proj-airi/plugin-sdk' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' import type { JsonSchema, Schema as StandardSchemaV1 } from 'xsschema' +import type { + PluginToolDefinitionRecord, + PluginToolsetPromptDefinitionRecord, + ToolsetPromptManifest, +} from './registry' + +import { defineKit } from '@proj-airi/plugin-sdk' import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host' import { parse } from 'valibot' import { toJsonSchema } from 'xsschema' -/** - * Describes the stage-tamagotchi gamelet API expected on `ctx.apis`. - * - * Use when: - * - Tool execution wants to open, configure, close, or inspect host-managed gamelet surfaces - * - Runtime validation needs a structural contract independent from `@proj-airi/plugin-sdk` - * - * Expects: - * - The stage-tamagotchi host contribution installs `gamelets` on the plugin session API object - * - * Returns: - * - The host-backed gamelet control surface exposed to tool callbacks - */ -export interface ToolExecutionGameletApi { - open: (id: string, params?: HostDataRecord) => Promise - configure: (id: string, patch: HostDataRecord) => Promise - request: (id: string, payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise - close: (id: string) => Promise - isOpen: (id: string) => Promise | boolean -} - -/** - * Describes the tamagotchi-flavored plugin context accepted by {@link defineToolset}. - * - * Use when: - * - A plugin host exposes tool registration plus the stage-owned `gamelets` surface - * - Tests want to model the runtime shape without relying on baked-in SDK typing - * - * Expects: - * - `apis.tools.register` is available - * - `apis.gamelets` is installed by the stage-tamagotchi host contribution - * - * Returns: - * - A context shape compatible with the tamagotchi tool helper - */ -export interface TamagotchiToolContext { - apis: Pick & { - gamelets: ToolExecutionGameletApi - } -} - -/** - * Describes the host services available while checking or executing a plugin tool. - * - * Use when: - * - Tool logic needs to orchestrate gamelet surfaces - * - * Expects: - * - All methods are provided by the host runtime, not the plugin - * - * Returns: - * - A runtime capability surface for tool execution - */ -export interface ToolExecutionContext { - gamelets: ToolExecutionGameletApi - - // TODO: - // Add character/runtime orchestration APIs after the gamelet/tool path is stable. -} +export type { + PluginToolDefinitionRecord, + PluginToolsetPromptDefinitionRecord, + RegisteredPluginToolDescriptor, + SerializedToolsetPromptDefinition, + SerializedXsaiToolDefinition, + SerializedXsaiToolsetDefinition, + ToolRegistryRecord, + ToolsetPromptManifest, + ToolsetPromptRegistryRecord, +} from './registry' +export { TamagotchiToolRegistry } from './registry' /** * Describes renderer-side discovery hints for a plugin tool. @@ -93,7 +53,7 @@ export interface PluginToolActivationDefinition { * - `inputSchema` is either an xsschema-compatible schema or a prebuilt JSON Schema object * * Returns: - * - A friendly authoring record consumed by {@link defineToolset} + * - A friendly authoring record consumed by {@link ToolKitClient.registerTool} */ export interface PluginToolDefinition { id: string @@ -101,59 +61,49 @@ export interface PluginToolDefinition { description: string activation?: PluginToolActivationDefinition inputSchema: TInputSchema - isAvailable?: (context: ToolExecutionContext) => Promise | boolean - execute: (input: unknown, context: ToolExecutionContext) => Promise | unknown + isAvailable?: () => Promise | boolean + execute: (input: unknown) => Promise | unknown } /** - * Declares a set of plugin tools in one call. - * - * Use when: - * - A plugin registers all of its tools during bootstrap - * - * Expects: - * - `ctx.apis.tools.register` is available from the host - * - * Returns: - * - Resolves once every tool has been registered with the host + * Describes one toolset prompt registration. */ -export interface DefineToolsetOptions { - id?: string - prompt?: ToolsetPromptManifest - tools: Array> +export interface PluginToolsetPromptRegistration { + id: string + prompt: ToolsetPromptManifest } -function isToolExecutionGameletApi(value: unknown): value is ToolExecutionGameletApi { - if (!value || typeof value !== 'object') { - return false - } +/** + * Describes the module-scoped tool authoring client exposed by {@link toolKit}. + * + * @param TInputSchema - Schema implementation accepted by each tool definition. + */ +export interface ToolKitClient { + /** + * Registers one tool through the host-owned tool registry. + */ + registerTool: (definition: PluginToolDefinition) => Promise - const candidate = value as Partial> - - return typeof candidate.open === 'function' - && typeof candidate.configure === 'function' - && typeof candidate.request === 'function' - && typeof candidate.close === 'function' - && typeof candidate.isOpen === 'function' + /** + * Registers one toolset prompt through the host-owned tool registry. + */ + registerToolsetPrompt: (registration: PluginToolsetPromptRegistration) => Promise } -function getToolExecutionGameletApi( - ctx: Pick | TamagotchiToolContext, -): ToolExecutionGameletApi { - const gamelets = (ctx.apis as Record).gamelets - - if (!isToolExecutionGameletApi(gamelets)) { - throw new Error('stage-tamagotchi gamelet API is not available on `ctx.apis.gamelets`.') - } - - return gamelets -} - -function createToolExecutionContext( - ctx: Pick | TamagotchiToolContext, -): ToolExecutionContext { - return { - gamelets: getToolExecutionGameletApi(ctx), +/** + * Describes host services required by the tool kit client. + */ +export interface ToolKitRuntime extends KitClientRuntime { + /** + * Host-owned tool registry operations. + */ + tools?: { + register: (input: { + tool: PluginToolDefinitionRecord + availability?: () => Promise | boolean + execute: (input: unknown) => Promise | unknown + }) => Promise | void + registerToolsetPrompt: (input: PluginToolsetPromptDefinitionRecord) => Promise | void } } @@ -316,48 +266,56 @@ async function serializeToolParameters(inputSchema: unknown): Promise | TamagotchiToolContext, - options: DefineToolsetOptions, -): Promise { - const executionContext = createToolExecutionContext(ctx) +export const toolKit = defineKit({ + id: 'kit.tool', + version: '1.0.0', + allowedExposePolicies: ['local-only', 'remote-observable'], + defaultExposePolicy: 'local-only', + createClient(runtime) { + const toolRuntime = runtime as ToolKitRuntime - if (options.prompt) { - await ctx.apis.tools.registerToolsetPrompt({ - id: options.id ?? options.prompt.id, - prompt: options.prompt, - }) - } + return { + async registerTool(definition) { + if (!toolRuntime.tools) { + throw new Error('toolKit requires a host tool registry runtime.') + } - for (const definition of options.tools) { - const isAvailable = definition.isAvailable + const isAvailable = definition.isAvailable - await ctx.apis.tools.register({ - tool: { - id: definition.id, - title: definition.title, - description: definition.description, - activation: { - keywords: definition.activation?.keywords ?? [], - patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source), - }, - parameters: await serializeToolParameters(definition.inputSchema), + await toolRuntime.tools.register({ + tool: { + id: definition.id, + title: definition.title, + description: definition.description, + activation: { + keywords: definition.activation?.keywords ?? [], + patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source), + }, + parameters: await serializeToolParameters(definition.inputSchema), + }, + availability: isAvailable, + execute: definition.execute, + }) }, - availability: isAvailable - ? () => isAvailable(executionContext) - : undefined, - execute: input => definition.execute(input, executionContext), - }) - } -} + async registerToolsetPrompt(registration) { + if (!toolRuntime.tools) { + throw new Error('toolKit requires a host tool registry runtime.') + } + + await toolRuntime.tools.registerToolsetPrompt(registration) + }, + } + }, +}) diff --git a/packages/plugin-sdk-tamagotchi/src/tools/registry.ts b/packages/plugin-sdk-tamagotchi/src/tools/registry.ts new file mode 100644 index 000000000..e61653c14 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/tools/registry.ts @@ -0,0 +1,238 @@ +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' + +/** + * Describes the user-facing metadata for a Tamagotchi extension tool. + */ +export interface RegisteredPluginToolDescriptor { + id: string + title: string + description: string + activation: { + keywords: string[] + patterns: string[] + } +} + +/** + * Describes the JSON-schema side of an xsai-compatible Tamagotchi extension tool. + */ +export interface SerializedXsaiToolDefinition { + ownerPluginId: string + name: string + description: string + parameters: HostDataRecord +} + +/** + * Describes model-facing guidance shared by every tool in one toolset. + */ +export interface ToolsetPromptManifest { + id: string + title?: string + content: string +} + +/** + * Captures one registered toolset prompt with extension ownership metadata. + */ +export interface SerializedToolsetPromptDefinition { + ownerPluginId: string + id: string + prompt: ToolsetPromptManifest +} + +/** + * Bundles xsai tools with their shared toolset prompt contributions. + */ +export interface SerializedXsaiToolsetDefinition { + tools: SerializedXsaiToolDefinition[] + prompts: SerializedToolsetPromptDefinition[] +} + +/** + * Captures the single source-of-truth definition submitted by a Tamagotchi extension. + */ +export interface PluginToolDefinitionRecord { + id: string + title: string + description: string + activation: { + keywords: string[] + patterns: string[] + } + parameters: HostDataRecord +} + +/** + * Captures an extension-owned prompt shared by a toolset. + */ +export interface PluginToolsetPromptDefinitionRecord { + id: string + prompt: ToolsetPromptManifest +} + +/** + * Stores one Tamagotchi extension tool registration inside the host runtime. + */ +export interface ToolRegistryRecord { + ownerSessionId: string + ownerPluginId: string + ownerModuleId?: string + tool: PluginToolDefinitionRecord + availability?: () => Promise | boolean + execute: (input: unknown) => Promise | unknown +} + +/** + * Stores one Tamagotchi extension toolset prompt registration inside the host runtime. + */ +export interface ToolsetPromptRegistryRecord { + ownerSessionId: string + ownerPluginId: string + ownerModuleId?: string + toolset: PluginToolsetPromptDefinitionRecord + availability?: () => Promise | boolean +} + +/** + * In-memory registry for Tamagotchi extension tools. + * + * Use when: + * - A Tamagotchi host needs to list extension tools for UI and xsai consumers + * - A Tamagotchi host needs to dispatch a tool invocation back to its owning extension + * + * Expects: + * - Callers provide extension session and optional module ownership during registration + * + * Returns: + * - Serializable metadata views and invoke routing + */ +export class TamagotchiToolRegistry { + private readonly tools = new Map() + private readonly toolsetPrompts = new Map() + + register(record: ToolRegistryRecord) { + const key = `${record.ownerPluginId}:${record.tool.id}` + this.tools.set(key, record) + return record + } + + registerToolsetPrompt(record: ToolsetPromptRegistryRecord) { + const key = `${record.ownerPluginId}:${record.toolset.id}` + this.toolsetPrompts.set(key, record) + return record + } + + unregister(ownerPluginId: string, toolId: string) { + return this.tools.delete(`${ownerPluginId}:${toolId}`) + } + + unregisterToolsetPrompt(ownerPluginId: string, toolsetId: string) { + return this.toolsetPrompts.delete(`${ownerPluginId}:${toolsetId}`) + } + + unregisterOwnerSession(ownerSessionId: string) { + for (const [key, record] of this.tools) { + if (record.ownerSessionId === ownerSessionId) { + this.tools.delete(key) + } + } + + for (const [key, record] of this.toolsetPrompts) { + if (record.ownerSessionId === ownerSessionId) { + this.toolsetPrompts.delete(key) + } + } + } + + unregisterOwnerScope(ownerSessionId: string, ownerModuleId?: string) { + for (const [key, record] of this.tools) { + if (record.ownerSessionId === ownerSessionId && record.ownerModuleId === ownerModuleId) { + this.tools.delete(key) + } + } + + for (const [key, record] of this.toolsetPrompts) { + if (record.ownerSessionId === ownerSessionId && record.ownerModuleId === ownerModuleId) { + this.toolsetPrompts.delete(key) + } + } + } + + clear() { + this.tools.clear() + this.toolsetPrompts.clear() + } + + async listAvailableDescriptors() { + const items: RegisteredPluginToolDescriptor[] = [] + + for (const record of this.tools.values()) { + if (await record.availability?.() === false) { + continue + } + + items.push({ + id: record.tool.id, + title: record.tool.title, + description: record.tool.description, + activation: { + keywords: [...record.tool.activation.keywords], + patterns: [...record.tool.activation.patterns], + }, + }) + } + + return items + } + + async listToolsetPrompts() { + const prompts: SerializedToolsetPromptDefinition[] = [] + + for (const record of this.toolsetPrompts.values()) { + if (await record.availability?.() === false) { + continue + } + + prompts.push({ + ownerPluginId: record.ownerPluginId, + id: record.toolset.id, + prompt: structuredClone(record.toolset.prompt), + }) + } + + return prompts + } + + async listSerializedXsaiTools(): Promise { + const items: SerializedXsaiToolDefinition[] = [] + + for (const record of this.tools.values()) { + if (await record.availability?.() === false) { + continue + } + + items.push({ + ownerPluginId: record.ownerPluginId, + name: record.tool.id, + description: record.tool.description, + parameters: structuredClone(record.tool.parameters), + }) + } + + return { + prompts: await this.listToolsetPrompts(), + tools: items, + } + } + + async invoke(ownerPluginId: string, toolId: string, input: unknown) { + const key = `${ownerPluginId}:${toolId}` + const record = this.tools.get(key) + if (!record) { + throw new Error(`Tamagotchi extension tool not found: ${key}`) + } + + return await record.execute(input) + } +} diff --git a/packages/plugin-sdk-tamagotchi/tsdown.config.ts b/packages/plugin-sdk-tamagotchi/tsdown.config.ts index 222909d13..3595bb291 100644 --- a/packages/plugin-sdk-tamagotchi/tsdown.config.ts +++ b/packages/plugin-sdk-tamagotchi/tsdown.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ 'src/index.ts', 'src/widgets/index.ts', 'src/gamelet/index.ts', + 'src/kits/gamelet/index.ts', + 'src/kits/tool/index.ts', 'src/tools/index.ts', ], dts: true, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 1800c99d0..ff499ab70 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -1,3 +1,66 @@ # @proj-airi/plugin-sdk -Runtime-agnostic SDK for AIRI plugins. +Runtime-agnostic SDK for AIRI extensions. + +## Kit API Naming + +Kits should hide transport details from extension authors. A normal extension should use a kit as a normal API object directly from setup: + +```ts +const gamelets = await ctx.kits.use(gameletKit) +await gamelets.mount(input) +``` + +Explicit module scopes are an advanced lifecycle and attribution API. Use `module.kits.use(...)` only when the host needs a contribution to be associated with a sub-scope that may later be inspected, disposed, or restarted independently. + +When a kit needs to work across process or network boundaries, expose shared Eventa invoke contracts from the kit package and build the client from those contracts. Do not introduce kit-specific transport method names such as `invokeGamelet`, `gameletRpc`, or `gameletRuntime`. + +Use these names consistently: + +| Name | Meaning | +| --- | --- | +| `gameletKitApis` | Shared Eventa API contract exported by the kit package. This is usually a map of `defineInvokeEventa(...)` entries. | +| `gameletKitService` | Host-side implementation of the kit behavior. It owns real side effects such as mounting, updating, and cleaning up UI. | +| `gameletKit` | The kit definition consumed by `ctx.kits.use(...)` or an optional module scope. It owns identity, version, availability policy, and client creation. | +| `gamelets` | The client instance returned to extension authors. Prefer a plural namespace when the client exposes multiple operations. | +| `createGameletKit(...)` | Factory that wires dependencies into `gameletKit`, including local client creation and remote Eventa-backed client creation. | + +Example shape: + +```ts +export const gameletKitApis = { + mount: defineInvokeEventa( + 'airi:kit:gamelet:mount', + ), +} + +export interface GameletKitService { + mount: (input: GameletMountInput, scope: KitCallScope) => Promise +} + +export function createGameletKit(options: { service: GameletKitService }) { + return defineKit({ + id: 'kit.gamelet', + version: '1.0.0', + createClient(runtime) { + return { + mount: input => options.service.mount(input, runtime), + } + }, + }) +} +``` + +Remote clients should reuse Eventa invoke instead of defining a parallel RPC protocol. Use a lazy context callback when the underlying transport can reconnect or be created after the client object: + +```ts +const mount = defineInvoke(getContext, gameletKitApis.mount) + +const gamelets = { + mount(input: GameletMountInput) { + return mount(input, scope) + }, +} +``` + +The shared artifact is the Eventa API contract, not the implementation function. Local clients may call `gameletKitService` directly; remote clients call the same API through Eventa. Both should expose the same authoring shape. diff --git a/packages/plugin-sdk/docs/design/multi-transport.md b/packages/plugin-sdk/docs/design/multi-transport.md index 540e92edc..b1ea27364 100644 --- a/packages/plugin-sdk/docs/design/multi-transport.md +++ b/packages/plugin-sdk/docs/design/multi-transport.md @@ -44,7 +44,7 @@ Eventa is context-oriented: contexts are created per transport (in-memory, WebSo - Designing the full plugin lifecycle orchestration (phase transitions, capability config, etc.). - Implementing a new transport stack beyond Eventa adapters (unless required by runtime gaps). -- Defining plugin packaging or distribution formats beyond `ManifestV1` entrypoints. +- Defining extension packaging or distribution formats beyond `ExtensionManifestV1` entrypoints. ## Proposal diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 9a4c12482..7182bba7a 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -43,10 +43,8 @@ "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", "@proj-airi/plugin-protocol": "workspace:*", - "@proj-airi/server-shared": "workspace:*", "nanoid": "catalog:", - "valibot": "catalog:", - "xstate": "catalog:" + "valibot": "catalog:" }, "devDependencies": { "es-toolkit": "catalog:" diff --git a/packages/plugin-sdk/src/channels/index.test.ts b/packages/plugin-sdk/src/channels/index.test.ts new file mode 100644 index 000000000..a311f5b1d --- /dev/null +++ b/packages/plugin-sdk/src/channels/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { createExtensionChannelScope, createModuleChannelScope } from './index' + +describe('scoped extension channels', () => { + it('creates independent extension and module scopes over the same context', () => { + const extension = createExtensionChannelScope({ + extensionId: 'airi-extension-test', + sessionId: 'session-1', + }) + const module = createModuleChannelScope(extension, { + moduleId: 'module-a', + }) + + expect(extension.identity.id).toBe('airi-extension-test') + expect(extension.identity.sessionId).toBe('session-1') + expect(module.identity.id).toBe('module-a') + expect(module.identity.extension).toEqual(extension.identity) + expect(module.context).toBe(extension.context) + }) +}) diff --git a/packages/plugin-sdk/src/channels/index.ts b/packages/plugin-sdk/src/channels/index.ts index d67f4bb33..62ab57f0b 100644 --- a/packages/plugin-sdk/src/channels/index.ts +++ b/packages/plugin-sdk/src/channels/index.ts @@ -1,67 +1,84 @@ import type { EventContext } from '@moeru/eventa' +import type { + ExtensionIdentity, + ExtensionModuleIdentity, +} from '@proj-airi/plugin-protocol/types' import { createContext } from '@moeru/eventa' /** - * Holds the active plugin-sdk channel contexts for the current process. - * - * Use when: - * - Bootstrapping local or remote plugin transports - * - Reading the current control-plane or data-plane Eventa context - * - * Expects: - * - Callers replace the fallback contexts with a concrete transport during startup - * - * Returns: - * - Mutable host and data channel references shared by the SDK runtime + * Describes one extension-scoped Eventa channel context. */ -export const channels = { - /** - * Channel for talking to Plugin Host. - * Can be seen as Control plane. - * - * createContext() here is for fallback internal channel preventing undefined access. - * In real usage, either local/* or remote/* channel implementation should be set as active channel. - */ - host: createContext(), - /** - * Channel for initialized plugin to transmit events to each other, includes plugins, and stage, configurator, etc. - * Can be seen as Data plane. - * - * createContext() here is for fallback internal channel preventing undefined access. - * In real usage, either local/* or remote/* channel implementation should be set as active channel. - */ - data: createContext(), +export interface ExtensionChannelScope { + /** Extension session identity associated with this scope. */ + identity: ExtensionIdentity + /** Eventa context that carries scoped extension/module traffic. */ + context: EventContext } /** - * Replaces the active control-plane channel used to talk to Plugin Host. - * - * Use when: - * - A runtime has created its concrete host transport context - * - * Expects: - * - `context` is compatible with the current plugin transport implementation - * - * Returns: - * - Nothing. Future reads from {@link channels}.host use the provided context. + * Describes one module-scoped Eventa channel context. */ -export function setActiveHostChannel(context: EventContext) { - channels.host = context +export interface ModuleChannelScope { + /** Module identity associated with this scope. */ + identity: ExtensionModuleIdentity + /** Eventa context shared with the owning extension scope. */ + context: EventContext } /** - * Replaces the active data-plane channel used for plugin-to-plugin or stage messaging. + * Creates an extension-scoped channel context. * * Use when: - * - A runtime has created its concrete data transport context + * - A host or transport adapter starts one extension session + * - Code needs identity metadata attached beside the Eventa context * * Expects: - * - `context` is compatible with the current plugin transport implementation + * - `extensionId` is the stable extension id + * - `context` is already bound to the desired transport when provided * * Returns: - * - Nothing. Future reads from {@link channels}.data use the provided context. + * - Extension identity plus the Eventa context used by child module scopes */ -export function setActiveDataChannel(context: EventContext) { - channels.data = context +export function createExtensionChannelScope(input: { + extensionId: string + sessionId?: string + version?: string + context?: EventContext +}): ExtensionChannelScope { + return { + identity: { + id: input.extensionId, + sessionId: input.sessionId, + version: input.version, + }, + context: input.context ?? createContext(), + } +} + +/** + * Creates a module-scoped channel context from an extension scope. + * + * Use when: + * - An extension registers a module that needs scoped protocol identity + * + * Expects: + * - `extension` is the owning extension channel scope + * - `moduleId` is stable within that extension session + * + * Returns: + * - Module identity plus the same Eventa context used by the extension + */ +export function createModuleChannelScope( + extension: ExtensionChannelScope, + input: { moduleId: string, labels?: Record }, +): ModuleChannelScope { + return { + identity: { + id: input.moduleId, + extension: extension.identity, + labels: input.labels, + }, + context: extension.context, + } } diff --git a/packages/plugin-sdk/src/channels/local/event-target/index.ts b/packages/plugin-sdk/src/channels/local/event-target/index.ts index f2e153e20..64adde075 100644 --- a/packages/plugin-sdk/src/channels/local/event-target/index.ts +++ b/packages/plugin-sdk/src/channels/local/event-target/index.ts @@ -17,6 +17,22 @@ export function createEventTargetHostChannel(eventTarget: EventTarget) { return createContext(eventTarget) } +/** + * Creates an extension Eventa transport backed by a local `EventTarget`. + * + * Use when: + * - A web-like host bridges extension traffic through an in-process event target + * + * Expects: + * - `eventTarget` dispatches and listens for the Eventa adapter event format + * + * Returns: + * - An Eventa context ready to pass into `createExtensionChannelScope` + */ +export function createEventTargetExtensionTransport(eventTarget: EventTarget) { + return createContext(eventTarget) +} + /** * Creates a data-plane Eventa context backed by a local `EventTarget`. * diff --git a/packages/plugin-sdk/src/channels/remote/websocket/index.ts b/packages/plugin-sdk/src/channels/remote/websocket/index.ts index c8f8b3ae7..56b3b9990 100644 --- a/packages/plugin-sdk/src/channels/remote/websocket/index.ts +++ b/packages/plugin-sdk/src/channels/remote/websocket/index.ts @@ -17,6 +17,22 @@ export function createWebSocketHostChannel(webSocket: WebSocket) { return createContext(webSocket) } +/** + * Creates an extension Eventa transport backed by a native `WebSocket`. + * + * Use when: + * - A peer transport carries extension protocol and invoke traffic over websocket + * + * Expects: + * - `webSocket` is already connected and managed by the caller + * + * Returns: + * - An Eventa context ready to pass into `createExtensionChannelScope` + */ +export function createWebSocketExtensionTransport(webSocket: WebSocket) { + return createContext(webSocket) +} + /** * Creates a data-plane Eventa context backed by a native `WebSocket`. * diff --git a/packages/plugin-sdk/src/channels/shared.ts b/packages/plugin-sdk/src/channels/shared.ts index 8543ce2e5..fefb0270a 100644 --- a/packages/plugin-sdk/src/channels/shared.ts +++ b/packages/plugin-sdk/src/channels/shared.ts @@ -4,7 +4,7 @@ import type { EventContext } from '@moeru/eventa' * Describes the control-plane Eventa context used between a plugin and its host. * * Use when: - * - Typing `ContextInit.channels.host` + * - Typing host-backed extension channels * - Passing a host-backed Eventa context through plugin bootstrap code * * Expects: diff --git a/packages/plugin-sdk/src/extension/define.ts b/packages/plugin-sdk/src/extension/define.ts new file mode 100644 index 000000000..009de5261 --- /dev/null +++ b/packages/plugin-sdk/src/extension/define.ts @@ -0,0 +1,19 @@ +import type { Extension } from './shared' + +/** + * Defines an AIRI extension entrypoint. + * + * Use when: + * - Authoring an extension package that runs setup code and may use host-provided kits + * - Keeping extension metadata and setup logic in one explicit export + * + * Expects: + * - `id` matches `extension.airi.json` + * - `setup` uses `ctx.kits` for the common kit authoring path + * + * Returns: + * - The extension definition consumed by an extension host loader + */ +export function defineExtension(extension: Extension): Extension { + return extension +} diff --git a/packages/plugin-sdk/src/extension/disposable.ts b/packages/plugin-sdk/src/extension/disposable.ts new file mode 100644 index 000000000..b2cb6c54f --- /dev/null +++ b/packages/plugin-sdk/src/extension/disposable.ts @@ -0,0 +1,47 @@ +/** + * Describes a disposable runtime resource owned by an extension or module. + */ +export interface Disposable { + /** Releases the resource. */ + dispose: () => void | Promise +} + +/** + * Stores disposable resources and releases them in reverse registration order. + * + * Use when: + * - Extension setup needs to collect session-level cleanup callbacks + * - Module registration needs scoped cleanup for watches, subscriptions, and bindings + * + * Expects: + * - Disposables are independent or tolerate reverse-order teardown + * + * Returns: + * - A disposable store that can be awaited during host cleanup + */ +export class DisposableStore implements Disposable { + private readonly disposables: Disposable[] = [] + private disposed = false + + add(disposable: Disposable) { + if (this.disposed) { + void disposable.dispose() + return disposable + } + + this.disposables.push(disposable) + return disposable + } + + async dispose() { + if (this.disposed) { + return + } + + this.disposed = true + for (const disposable of [...this.disposables].reverse()) { + await disposable.dispose() + } + this.disposables.length = 0 + } +} diff --git a/packages/plugin-sdk/src/extension/index.test.ts b/packages/plugin-sdk/src/extension/index.test.ts new file mode 100644 index 000000000..8ccf91eff --- /dev/null +++ b/packages/plugin-sdk/src/extension/index.test.ts @@ -0,0 +1,149 @@ +import type { ExtensionModuleContext, ExtensionSetupContext } from './index' + +import { describe, expect, it, vi } from 'vitest' + +import { createModule, defineExtension, DisposableStore } from './index' + +function createTestExtensionContext(register: ExtensionSetupContext['modules']['register']): ExtensionSetupContext { + return { + extension: { id: 'extension-test', sessionId: 'session-1', version: '1.0.0' }, + subscriptions: new DisposableStore(), + kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() }, + modules: { register }, + } +} + +function createTestModule(id: string, dispose = vi.fn(async () => {})): ExtensionModuleContext { + return { + id, + identity: { + id, + extension: { + id: 'extension-test', + sessionId: 'session-1', + version: '1.0.0', + }, + }, + permissions: {}, + kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() }, + subscriptions: new DisposableStore(), + dispose, + } +} + +describe('defineExtension', () => { + it('defines an extension with setup and module registration context', async () => { + const setup = vi.fn(async () => {}) + const extension = defineExtension({ + id: 'airi-extension-test', + version: '1.0.0', + setup, + }) + + expect(extension.id).toBe('airi-extension-test') + expect(extension.version).toBe('1.0.0') + + const subscriptions = new DisposableStore() + await extension.setup({ + extension: { + id: extension.id, + version: extension.version, + sessionId: 'session-1', + }, + subscriptions, + kits: { + use: vi.fn(), + tryUse: vi.fn(), + watch: vi.fn(), + }, + modules: { + register: vi.fn(), + }, + }) + + expect(setup).toHaveBeenCalledTimes(1) + }) +}) + +describe('createModule', () => { + it('registers a module with an explicit id and returns a narrow module ref', async () => { + const dispose = vi.fn(async () => {}) + const module = createTestModule('module-explicit', dispose) + const register = vi.fn(async () => module) + const ctx = createTestExtensionContext(register) + + const ref = await createModule(ctx, { id: 'module-explicit' }) + + expect(register).toHaveBeenCalledWith({ id: 'module-explicit' }) + expect(ref).toStrictEqual({ + id: 'module-explicit', + kits: module.kits, + subscriptions: module.subscriptions, + dispose: expect.any(Function), + }) + expect(ref).not.toHaveProperty('identity') + expect(ref).not.toHaveProperty('permissions') + expect(ref.dispose).not.toBe(module.dispose) + + await ctx.subscriptions.dispose() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('disposes the underlying module once when the returned ref and setup context are both disposed', async () => { + const dispose = vi.fn(async () => {}) + const module = createTestModule('module-idempotent', dispose) + const register = vi.fn(async () => module) + const ctx = createTestExtensionContext(register) + + const ref = await createModule(ctx, { id: 'module-idempotent' }) + + await ref.dispose() + await ctx.subscriptions.dispose() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('waits for the in-flight module dispose when the returned ref and setup context dispose concurrently', async () => { + let resolveDispose!: () => void + const controlledDispose = new Promise((resolve) => { + resolveDispose = resolve + }) + const dispose = vi.fn(() => controlledDispose) + const module = createTestModule('module-concurrent', dispose) + const register = vi.fn(async () => module) + const ctx = createTestExtensionContext(register) + + const ref = await createModule(ctx, { id: 'module-concurrent' }) + + const refDispose = ref.dispose() + let ctxDisposeCompleted = false + const ctxDispose = ctx.subscriptions.dispose().then(() => { + ctxDisposeCompleted = true + }) + + await Promise.resolve() + await Promise.resolve() + + expect(dispose).toHaveBeenCalledTimes(1) + expect(ctxDisposeCompleted).toBe(false) + + resolveDispose() + await Promise.all([refDispose, ctxDispose]) + + expect(ctxDisposeCompleted).toBe(true) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('registers a module with a generated id when no explicit id is provided', async () => { + const register = vi.fn(async ({ id }) => createTestModule(id)) + const ctx = createTestExtensionContext(register) + + const ref = await createModule(ctx) + + expect(register).toHaveBeenCalledTimes(1) + expect(register.mock.calls[0]?.[0].id).toEqual(expect.any(String)) + expect(register.mock.calls[0]?.[0].id).not.toBe('') + expect(ref.id).toBe(register.mock.calls[0]?.[0].id) + }) +}) diff --git a/packages/plugin-sdk/src/extension/index.ts b/packages/plugin-sdk/src/extension/index.ts new file mode 100644 index 000000000..143d40e4e --- /dev/null +++ b/packages/plugin-sdk/src/extension/index.ts @@ -0,0 +1,43 @@ +import type { ExtensionModuleRef, ExtensionSetupContext } from './shared' + +import { nanoid } from 'nanoid/non-secure' + +export * from './define' +export * from './disposable' +export type * from './shared' + +/** + * Creates a module scope owned by the current extension setup. + * + * Use when: + * - A contribution needs module-scoped kits or cleanup + * - The extension should dispose the module with the setup session + * + * Expects: + * - `ctx` comes from the active extension setup call + * - `options.id`, when provided, is stable within the extension session + * + * Returns: + * - A narrow module reference that hides host identity and permission internals + */ +export async function createModule( + ctx: ExtensionSetupContext, + options: { id?: string } = {}, +): Promise { + const id = options.id ?? nanoid() + const module = await ctx.modules.register({ id }) + let disposePromise: Promise | undefined + const dispose = () => { + disposePromise ??= module.dispose() + return disposePromise + } + + ctx.subscriptions.add({ dispose }) + + return { + id: module.id, + kits: module.kits, + subscriptions: module.subscriptions, + dispose, + } +} diff --git a/packages/plugin-sdk/src/extension/shared.ts b/packages/plugin-sdk/src/extension/shared.ts new file mode 100644 index 000000000..68ce1d124 --- /dev/null +++ b/packages/plugin-sdk/src/extension/shared.ts @@ -0,0 +1,106 @@ +import type { + ExtensionIdentity, + ExtensionModuleIdentity, + ModulePermissionDeclaration, + ModulePermissionGrant, +} from '@proj-airi/plugin-protocol/types' + +import type { KitAvailability, KitRef, KitUseResult } from '../kit' +import type { Disposable, DisposableStore } from './disposable' + +/** + * Describes an optional advanced lifecycle/attribution scope inside an extension session. + */ +export interface RegisterExtensionModuleInput { + /** Stable module id within the current extension session. */ + id: string + /** + * Runtime permissions this module actually intends to use. + * + * The host intersects these requests with the extension manifest grant, so a + * module can never widen access beyond the package/session-level ceiling. + */ + permissions?: ModulePermissionDeclaration + /** Optional labels used for routing, policy, and inspection. */ + labels?: Record +} + +/** + * Minimal kit client registry exposed to extension setup and optional module scopes. + */ +export interface ExtensionKitRegistry { + use: (kit: KitRef) => Promise + tryUse: (kit: KitRef) => Promise> + watch: ( + kit: KitRef, + callback: (availability: KitAvailability) => void | Promise, + ) => Disposable +} + +/** + * Runtime context returned from module registration. + */ +export interface ExtensionModuleContext { + /** Stable module id within the current extension session. */ + id: string + /** Protocol identity for this module. */ + identity: ExtensionModuleIdentity + /** Effective grant after applying the extension-level permission ceiling. */ + permissions: ModulePermissionGrant + /** Module-scoped kit access for attribution and optional lifecycle cleanup. */ + kits: ExtensionKitRegistry + /** Cleanup callbacks owned by this module. */ + subscriptions: DisposableStore + /** Disposes module-owned resources. */ + dispose: () => Promise +} + +/** + * Narrow module reference exposed to extension authors. + */ +export interface ExtensionModuleRef { + /** Stable module id within the current extension session. */ + id: string + /** Module-scoped kit access for attribution and optional lifecycle cleanup. */ + kits: ExtensionKitRegistry + /** Cleanup callbacks owned by this module. */ + subscriptions: DisposableStore + /** Disposes module-owned resources. */ + dispose: () => Promise +} + +/** + * Optional module scope API exposed during extension setup. + * + * Modules are not required for basic kit usage. Use them when a host needs a + * contribution to have its own cleanup, inspection, or future restart boundary. + */ +export interface ExtensionModuleRegistry { + register: (input: RegisterExtensionModuleInput) => Promise +} + +/** + * Host-provided setup context for one extension session. + */ +export interface ExtensionSetupContext { + /** Current extension session identity. */ + extension: ExtensionIdentity + /** Extension-session cleanup callbacks. */ + subscriptions: DisposableStore + /** Extension-scoped kit access for the common authoring path. */ + kits: ExtensionKitRegistry + /** Optional advanced lifecycle/attribution scopes. */ + modules: ExtensionModuleRegistry +} + +/** + * Public extension authoring contract returned by `defineExtension`. + */ +export interface Extension { + /** Stable extension id from the manifest/package. */ + id: string + /** Optional extension package version. */ + version?: string + /** Runs extension initialization. */ + setup: (ctx: ExtensionSetupContext) => Promise | void +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 573a4ab59..a1d3b61c8 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,16 +1,5 @@ console.warn('@proj-airi/plugin-sdk is currently working in progress. APIs may change without warning.') +export * from './extension' +export * from './kit' export * from './plugin' -/** - * Re-exports the plugin bootstrap contracts from the package root. - * - * Use when: - * - Consumers want the high-level plugin authoring types from `@proj-airi/plugin-sdk` - * - * Expects: - * - Downstream code imports from the package root instead of the internal path - * - * Returns: - * - The `ContextInit` and `Plugin` types from `./plugin/shared` - */ -export type { ContextInit, Plugin } from './plugin/shared' diff --git a/packages/plugin-sdk/src/kit/errors.ts b/packages/plugin-sdk/src/kit/errors.ts new file mode 100644 index 000000000..e3b1d73ca --- /dev/null +++ b/packages/plugin-sdk/src/kit/errors.ts @@ -0,0 +1,12 @@ +/** + * Error raised when a module cannot use a requested kit. + */ +export class KitUnavailableError extends Error { + constructor( + readonly kitId: string, + readonly reason: 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready', + ) { + super(`Kit \`${kitId}\` is unavailable: ${reason}.`) + this.name = 'KitUnavailableError' + } +} diff --git a/packages/plugin-sdk/src/kit/index.test.ts b/packages/plugin-sdk/src/kit/index.test.ts new file mode 100644 index 000000000..d3c595924 --- /dev/null +++ b/packages/plugin-sdk/src/kit/index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { DisposableStore } from '../extension/disposable' +import { defineKit, kitUseFailure } from './index' + +describe('defineKit', () => { + it('defines a typed kit reference with expose policy metadata', () => { + const kit = defineKit({ + id: 'kit.test', + version: '1.0.0', + allowedExposePolicies: ['local-only', 'remote-observable'], + defaultExposePolicy: 'local-only', + createClient: runtime => ({ + identity: `${runtime.extensionId}:${runtime.moduleId}`, + }), + }) + + expect(kit.id).toBe('kit.test') + expect(kit.defaultExposePolicy).toBe('local-only') + expect(kit.createClient({ + extensionId: 'extension-a', + sessionId: 'session-a', + moduleId: 'module-a', + subscriptions: new DisposableStore(), + }).identity).toBe('extension-a:module-a') + }) + + it('creates typed kit use failures', () => { + const kit = defineKit({ + id: 'kit.missing', + version: '1.0.0', + createClient: () => ({}), + }) + + const result = kitUseFailure(kit, 'missing-kit') + + expect(result.ok).toBe(false) + if (result.ok) { + throw new Error('Expected kit use to fail.') + } + expect(result.reason).toBe('missing-kit') + expect(result.error.message).toContain('kit.missing') + }) +}) diff --git a/packages/plugin-sdk/src/kit/index.ts b/packages/plugin-sdk/src/kit/index.ts new file mode 100644 index 000000000..77a13520c --- /dev/null +++ b/packages/plugin-sdk/src/kit/index.ts @@ -0,0 +1,97 @@ +import type { Disposable, DisposableStore } from '../extension/disposable' + +import { KitUnavailableError } from './errors' + +export type ExposePolicy = 'local-only' | 'remote-observable' | 'remote-callable' + +/** + * Host-provided runtime values used to create a scope-aware kit client. + */ +export interface KitClientRuntime { + /** Stable extension id. */ + extensionId: string + /** Host-assigned extension session id. */ + sessionId: string + /** Stable module id when the kit client is created for an explicit module scope. */ + moduleId?: string + /** Cleanup store for the current extension or module scope. */ + subscriptions: DisposableStore +} + +/** + * Defines one kit API surface available to extension setup and optional module scopes. + * + * Kits that support remote use should expose kit-owned Eventa API contracts + * such as `gameletKitApis`, then build local and remote clients with the same + * authoring shape. Keep transport/RPC words out of the author-facing client: + * authors should use `gamelets.mount(...)`, not `invokeGameletMount(...)`. + * + * @param TClient Kit client type returned to extension authors. + */ +export interface KitRef { + /** Stable kit id. */ + id: string + /** Kit API version used for compatibility checks. */ + version: string + /** Exposure policies this kit can support across host/peer boundaries. */ + allowedExposePolicies?: ExposePolicy[] + /** Default exposure policy when module/host policy does not override it. */ + defaultExposePolicy?: ExposePolicy + /** Creates a scope-aware client for this kit. */ + createClient: (runtime: KitClientRuntime) => TClient +} + +export type KitUnavailableReason = 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready' + +export type KitUseResult + = | { ok: true, client: TClient } + | { ok: false, reason: KitUnavailableReason, error: Error } + +export type KitAvailability + = | { available: true, kit: KitRef, client: TClient } + | { available: false, kit: KitRef, reason: KitUnavailableReason, error: Error } + +/** + * Defines a kit reference. + * + * Use when: + * - Implementing a host-provided or extension-provided kit API surface + * - Publishing a typed kit that extensions can pass to `ctx.kits.use(...)` + * + * Expects: + * - `id` is stable across versions + * - `createClient` returns an extension- or module-scoped API object + * + * Returns: + * - The kit reference consumed by host kit registries, extension setup, and optional module scopes + */ +export function defineKit(kit: KitRef): KitRef { + return kit +} + +/** + * Creates a standard failed result for optional kit usage. + * + * Use when: + * - Implementing `ctx.kits.tryUse(...)` or optional module-scoped kit usage + * - Returning a typed reason without throwing + * + * Expects: + * - `reason` describes the host-side availability decision + * + * Returns: + * - A discriminated failure result with `KitUnavailableError` + */ +export function kitUseFailure( + kit: KitRef, + reason: KitUnavailableReason, +): Extract, { ok: false }> { + return { + ok: false, + reason, + error: new KitUnavailableError(kit.id, reason), + } +} + +export type { Disposable } +export * from './errors' diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index bbf556c08..295be6c60 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -1,45 +1,760 @@ -import type { ModulePermissionDeclaration } from './shared/types' +import type { ExtensionManifestV1, ModulePermissionDeclaration } from './shared/types' import { join } from 'node:path' -import { createContext, defineEventa, defineInvoke, defineInvokeHandler } from '@moeru/eventa' -import { - moduleCompatibilityResult, - modulePermissionsCurrent, - modulePermissionsDeclare, - modulePermissionsDenied, - modulePermissionsGranted, - modulePermissionsRequest, - moduleStatus, - registryModulesSync, -} from '@proj-airi/plugin-protocol/types' +import { safeParse } from 'valibot' import { describe, expect, it, vi } from 'vitest' -import { FileSystemLoader, PluginHost } from '.' -import { createApis } from '../plugin/apis/client' -import { protocolCapabilityWait, protocolProviders } from '../plugin/apis/protocol' +import { ExtensionHost, extensionManifestV1Schema, FileSystemLoader } from '.' +import { defineExtension } from '../extension' +import { defineKit } from '../kit' -function assertNever(value: never): never { - throw new Error(`Unsupported capability state: ${value}`) -} +describe('extension manifest schema', () => { + it('accepts extension.airi.json v1 manifests', () => { + const result = safeParse(extensionManifestV1Schema, { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-test', + permissions: {}, + entrypoints: { + electron: './extension.mjs', + }, + }) -function reportPluginCapability( - host: PluginHost, - payload: { key: string, state: 'announced' | 'ready', metadata?: Record }, -) { - switch (payload.state) { - case 'announced': - return host.announceCapability(payload.key, payload.metadata) + expect(result.success).toBe(true) + }) - case 'ready': - return host.markCapabilityReady(payload.key, payload.metadata) + it('rejects legacy extension manifests', () => { + const result = safeParse(extensionManifestV1Schema, { + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'airi-plugin-test', + permissions: {}, + entrypoints: { + electron: './plugin.mjs', + }, + }) - default: - return assertNever(payload.state) - } -} + expect(result.success).toBe(false) + }) +}) -describe('for FileSystemPluginHost', () => { +describe('for ExtensionHost', () => { + it('runs extension setup and registers multiple module sessions', async () => { + const host = new ExtensionHost() + const extension = defineExtension({ + id: 'airi-extension-test', + async setup(ctx) { + await ctx.modules.register({ id: 'module-a' }) + await ctx.modules.register({ id: 'module-b' }) + }, + }) + + const session = await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-test', + permissions: {}, + entrypoints: {}, + }, + }) + + expect(session.extension.id).toBe('airi-extension-test') + expect(host.listModules().map(module => module.id)).toEqual(['module-a', 'module-b']) + }) + + it('rejects defineExtension entrypoint ids that do not match the manifest id', async () => { + const host = new ExtensionHost() + const extension = defineExtension({ + id: 'airi-extension-entrypoint-id', + async setup() {}, + }) + + await expect(host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-manifest-id', + permissions: {}, + entrypoints: {}, + }, + })).rejects.toThrow( + 'Extension entrypoint id `airi-extension-entrypoint-id` must match manifest id `airi-extension-manifest-id`.', + ) + }) + + it('disposes modules registered before setup failure', async () => { + const disposed: string[] = [] + const host = new ExtensionHost() + const extension = defineExtension({ + id: 'airi-extension-failing', + async setup(ctx) { + const first = await ctx.modules.register({ id: 'first' }) + first.subscriptions.add({ + dispose: () => { + disposed.push('first-subscription') + }, + }) + const second = await ctx.modules.register({ id: 'second' }) + second.subscriptions.add({ + dispose: () => { + disposed.push('second-subscription') + }, + }) + throw new Error('setup failed') + }, + }) + + await expect(host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-failing', + permissions: {}, + entrypoints: {}, + }, + })).rejects.toThrow('setup failed') + + expect(disposed).toEqual(['second-subscription', 'first-subscription']) + expect(host.listModules()).toEqual([]) + }) + + it('cleans up extension kit resources registered before setup failure', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.cleanup-failure', + version: '1.0.0', + createClient: runtime => ({ + bind() { + return host.bindExtensionKitModule(runtime.sessionId, { + moduleId: 'cleanup-failure-gamelet', + kitId: 'kit.cleanup-failure', + kitModuleType: 'gamelet', + config: {}, + }) + }, + }), + }) + host.registerKit({ + kitId: 'kit.cleanup-failure', + version: '1.0.0', + runtimes: ['electron'], + capabilities: [], + }) + host.registerKitApi(kit) + const extension = defineExtension({ + id: 'airi-extension-cleanup-failure', + async setup(ctx) { + const client = await ctx.kits.use(kit) + client.bind() + throw new Error('setup failed after resource registration') + }, + }) + + await expect(host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-cleanup-failure', + permissions: { + apis: [ + { key: 'kit.cleanup-failure', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:resources:kits:kit.cleanup-failure:bindings', actions: ['write'] }, + ], + }, + entrypoints: {}, + }, + })).rejects.toThrow('setup failed after resource registration') + + expect(host.listBindings()).toEqual([]) + }) + + /** + * @example + * expect(host.listBindings()).toEqual([]) + */ + it('cleans up module-scoped kit resources when the module is disposed', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.module-dispose', + version: '1.0.0', + createClient: runtime => ({ + bind() { + return host.bindExtensionKitModule(runtime.sessionId, { + moduleId: 'module-dispose-gamelet', + kitId: 'kit.module-dispose', + kitModuleType: 'gamelet', + config: {}, + }, runtime.moduleId) + }, + }), + }) + host.registerKit({ + kitId: 'kit.module-dispose', + version: '1.0.0', + runtimes: ['electron'], + capabilities: [], + }) + host.registerKitApi(kit) + const permissions: ModulePermissionDeclaration = { + apis: [ + { key: 'kit.module-dispose', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:resources:kits:kit.module-dispose:bindings', actions: ['write'] }, + ], + } + const extension = defineExtension({ + id: 'airi-extension-module-dispose', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'module-dispose', + permissions, + }) + const client = await module.kits.use(kit) + client.bind() + + await module.dispose() + }, + }) + + const session = await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-module-dispose', + permissions, + entrypoints: {}, + }, + }) + + expect(session.phase).toBe('ready') + expect(host.listModules()).toEqual([]) + expect(host.listBindings()).toEqual([]) + }) + + it('lets extension setup use granted kits without registering a module', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.extension-direct', + version: '1.0.0', + createClient: runtime => ({ + ping: () => `${runtime.extensionId}:${runtime.sessionId}:${runtime.moduleId ?? 'root'}`, + }), + }) + host.registerKitApi(kit) + + let observed = '' + const extension = defineExtension({ + id: 'airi-extension-direct-kit', + async setup(ctx) { + const client = await ctx.kits.use(kit) + observed = client.ping() + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit', + permissions: { + apis: [{ key: 'kit.extension-direct', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + + expect(observed).toContain('airi-extension-direct-kit:') + expect(observed).toContain(':root') + expect(host.listModules()).toEqual([]) + }) + + it('denies extension-scoped kit use when the extension grant does not allow the kit', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.extension-denied', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const extension = defineExtension({ + id: 'airi-extension-direct-kit-denied', + async setup(ctx) { + const result = await ctx.kits.tryUse(kit) + expect(result.ok).toBe(false) + if (!('reason' in result)) { + throw new Error('Expected direct kit use to be denied.') + } + expect(result.reason).toBe('permission-denied') + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit-denied', + permissions: { + apis: [{ key: 'kit.other', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + }) + + it('denies extension-scoped kit use when host permission resolver narrows the manifest grant', async () => { + const host = new ExtensionHost({ + permissionResolver: () => ({ + apis: [{ key: 'kit.other', actions: ['invoke'] }], + }), + }) + const kit = defineKit({ + id: 'kit.extension-resolver-denied', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const extension = defineExtension({ + id: 'airi-extension-direct-kit-resolver-denied', + async setup(ctx) { + const result = await ctx.kits.tryUse(kit) + expect(result.ok).toBe(false) + if (!('reason' in result)) { + throw new Error('Expected direct kit use to be denied.') + } + expect(result.reason).toBe('permission-denied') + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit-resolver-denied', + permissions: { + apis: [{ key: 'kit.extension-resolver-denied', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + }) + + it('does not let persisted grants override a later permission resolver decision', async () => { + let grantRequestedKit = true + const host = new ExtensionHost({ + permissionResolver: () => ({ + apis: [{ + key: grantRequestedKit ? 'kit.extension-persisted-revoked' : 'kit.other', + actions: ['invoke'], + }], + }), + }) + const kit = defineKit({ + id: 'kit.extension-persisted-revoked', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const manifest = { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit-persisted-revoked', + permissions: { + apis: [{ key: 'kit.extension-persisted-revoked', actions: ['invoke'] }], + }, + entrypoints: {}, + } satisfies ExtensionManifestV1 + + const grantedExtension = defineExtension({ + id: 'airi-extension-direct-kit-persisted-revoked', + async setup(ctx) { + const result = await ctx.kits.tryUse(kit) + expect(result.ok).toBe(true) + }, + }) + + await host.startExtension(grantedExtension, { manifest }) + + grantRequestedKit = false + const revokedExtension = defineExtension({ + id: 'airi-extension-direct-kit-persisted-revoked', + async setup(ctx) { + const result = await ctx.kits.tryUse(kit) + expect(result.ok).toBe(false) + if (!('reason' in result)) { + throw new Error('Expected direct kit use to be denied.') + } + expect(result.reason).toBe('permission-denied') + }, + }) + + await host.startExtension(revokedExtension, { manifest }) + }) + + it('lets module-scoped kit use inherit the extension grant when module permissions are omitted', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.module-inherited-grant', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const extension = defineExtension({ + id: 'airi-extension-module-inherited-grant', + async setup(ctx) { + const module = await ctx.modules.register({ id: 'module-a' }) + const result = await module.kits.tryUse(kit) + + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error('Expected inherited module kit use to be allowed.') + } + expect(result.client.ping()).toBe('pong') + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-module-inherited-grant', + permissions: { + apis: [{ key: 'kit.module-inherited-grant', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + }) + + it('denies module-scoped kit use when host permission resolver narrows the extension grant', async () => { + const host = new ExtensionHost({ + permissionResolver: () => ({ + apis: [{ key: 'kit.other', actions: ['invoke'] }], + }), + }) + const kit = defineKit({ + id: 'kit.module-resolver-denied', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const extension = defineExtension({ + id: 'airi-extension-module-kit-resolver-denied', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'module-a', + permissions: { + apis: [{ key: 'kit.module-resolver-denied', actions: ['invoke'] }], + }, + }) + const result = await module.kits.tryUse(kit) + expect(result.ok).toBe(false) + if (!('reason' in result)) { + throw new Error('Expected module kit use to be denied.') + } + expect(result.reason).toBe('permission-denied') + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-module-kit-resolver-denied', + permissions: { + apis: [{ key: 'kit.module-resolver-denied', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + }) + + it('lets extension setup watch kit availability without registering a module', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.extension-watch', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + + const observed: boolean[] = [] + const extension = defineExtension({ + id: 'airi-extension-direct-kit-watch', + async setup(ctx) { + ctx.kits.watch(kit, (availability) => { + observed.push(availability.available) + }) + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit-watch', + permissions: { + apis: [{ key: 'kit.extension-watch', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + + host.registerKitApi(kit) + + expect(observed).toEqual([false, true]) + }) + + it('disposes extension-scoped kit availability watchers with the extension session', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.extension-watch-dispose', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + + const observed: boolean[] = [] + const extension = defineExtension({ + id: 'airi-extension-direct-kit-watch-dispose', + async setup(ctx) { + ctx.kits.watch(kit, (availability) => { + observed.push(availability.available) + }) + }, + }) + + const session = await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-direct-kit-watch-dispose', + permissions: { + apis: [{ key: 'kit.extension-watch-dispose', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + + await session.subscriptions.dispose() + host.registerKitApi(kit) + + expect(observed).toEqual([false]) + }) + + it('supports required, optional, and watched kit availability', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.test', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + let watched = false + const extension = defineExtension({ + id: 'airi-extension-kit-test', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'module-a', + permissions: { + apis: [{ key: 'kit.test', actions: ['invoke'] }], + }, + }) + const client = await module.kits.use(kit) + expect(client.ping()).toBe('pong') + + const result = await module.kits.tryUse(kit) + expect(result.ok).toBe(true) + + module.kits.watch(kit, (availability) => { + watched = availability.available + }) + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-kit-test', + permissions: { + apis: [{ key: 'kit.*', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + + expect(watched).toBe(true) + }) + + it('disposes module-scoped kit availability watchers with the module scope', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.module-watch-dispose', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + + const observed: boolean[] = [] + let disposeModule: (() => Promise) | undefined + const extension = defineExtension({ + id: 'airi-extension-module-kit-watch-dispose', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'module-a', + permissions: { + apis: [{ key: 'kit.module-watch-dispose', actions: ['invoke'] }], + }, + }) + disposeModule = module.dispose + module.kits.watch(kit, (availability) => { + observed.push(availability.available) + }) + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-module-kit-watch-dispose', + permissions: { + apis: [{ key: 'kit.module-watch-dispose', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + + if (!disposeModule) { + throw new Error('Expected module scope to be registered.') + } + await disposeModule() + host.registerKitApi(kit) + + expect(observed).toEqual([false]) + }) + + it('rejects duplicate module ids without replacing the registered module', async () => { + const host = new ExtensionHost() + const disposed: string[] = [] + const extension = defineExtension({ + id: 'airi-extension-duplicate-module', + async setup(ctx) { + const first = await ctx.modules.register({ id: 'module-a' }) + first.subscriptions.add({ + dispose: () => { + disposed.push('first') + }, + }) + + await expect(ctx.modules.register({ id: 'module-a' })).rejects.toThrow( + 'Extension module `module-a` is already registered', + ) + }, + }) + + const session = await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-duplicate-module', + permissions: {}, + entrypoints: {}, + }, + }) + + expect([...session.modules.keys()]).toEqual(['module-a']) + + await host.stop(session.id) + + expect(disposed).toEqual(['first']) + }) + + it('waits for async extension module cleanup while stopping a defineExtension session', async () => { + const host = new ExtensionHost() + const cleanupOrder: string[] = [] + const extension = defineExtension({ + id: 'airi-extension-async-stop-cleanup', + async setup(ctx) { + const module = await ctx.modules.register({ id: 'module-a' }) + module.subscriptions.add({ + dispose: async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + cleanupOrder.push('module-cleanup') + }, + }) + }, + }) + + const session = await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-async-stop-cleanup', + permissions: {}, + entrypoints: {}, + }, + }) + + const stopped = host.stop(session.id) + + expect(cleanupOrder).toEqual([]) + + await stopped + + expect(cleanupOrder).toEqual(['module-cleanup']) + }) + + it('denies kit use when module permissions exceed the extension grant ceiling', async () => { + const host = new ExtensionHost() + const kit = defineKit({ + id: 'kit.denied', + version: '1.0.0', + createClient: () => ({ ping: () => 'pong' }), + }) + host.registerKitApi(kit) + + const extension = defineExtension({ + id: 'airi-extension-kit-denied', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'module-a', + permissions: { + apis: [{ key: 'kit.denied', actions: ['invoke'] }], + }, + }) + const result = await module.kits.tryUse(kit) + expect(result.ok).toBe(false) + if (!('reason' in result)) { + throw new Error('Expected kit use to be denied.') + } + expect(result.reason).toBe('permission-denied') + }, + }) + + await host.startExtension(extension, { + manifest: { + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'airi-extension-kit-denied', + permissions: { + apis: [{ key: 'kit.other', actions: ['invoke'] }], + }, + entrypoints: {}, + }, + }) + }) +}) + +describe('for FileSystemLoader', () => { const testPermissions: ModulePermissionDeclaration = { apis: [ { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, @@ -53,65 +768,121 @@ describe('for FileSystemPluginHost', () => { ], } - it('should load test-normal-plugin from manifest', async () => { - const host = new FileSystemLoader() + /** + * @example + * expect(host.listModules().map(module => module.id)).toEqual(['defined-extension-module']) + */ + it('loads defineExtension entrypoints from extension manifests', async () => { + const host = new ExtensionHost() - const pluginDef = await host.loadPluginFor({ + await host.start({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin', - permissions: testPermissions, + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-define-extension-entrypoint', + permissions: {}, entrypoints: { - electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + electron: join(import.meta.dirname, 'testdata', 'test-define-extension-entrypoint.ts'), }, }, { cwd: '', runtime: 'electron' }) - const ctx = createContext() - const apis = createApis(ctx) - const onVitestCall = vi.fn() - ctx.on(defineEventa('vitest-call:init'), onVitestCall) - - await expect(pluginDef.init?.({ channels: { host: ctx }, apis })).resolves.not.toThrow() - expect(onVitestCall).toHaveBeenCalledTimes(1) + expect(host.listModules().map(module => module.id)).toEqual(['defined-extension-module']) }) - it('should resolve runtime-specific entrypoint with node fallback', async () => { + /** + * @example + * expect(host.listModules()).toEqual([]) + */ + it('stops defineExtension entrypoint sessions loaded through host.start', async () => { + const host = new ExtensionHost() + const entrypointPath = join(import.meta.dirname, 'testdata', 'test-stoppable-extension-entrypoint.ts') + const testEntrypoint = await import('./testdata/test-stoppable-extension-entrypoint') + testEntrypoint.disposedSessionIds.splice(0) + + const session = await host.start({ + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-stoppable-extension-entrypoint', + permissions: {}, + entrypoints: { + electron: entrypointPath, + }, + }, { cwd: '', runtime: 'electron' }) + + expect(host.listModules().map(module => module.id)).toEqual(['stoppable-extension-module']) + + host.stop(session.id) + + await vi.waitFor(() => { + expect(testEntrypoint.disposedSessionIds).toEqual([session.id]) + }) + expect(host.listModules()).toEqual([]) + }) + + /** + * @example + * expect(reloaded.phase).toBe('ready') + */ + it('reloads defineExtension entrypoint sessions loaded through host.start', async () => { + const host = new ExtensionHost() + const entrypointPath = join(import.meta.dirname, 'testdata', 'test-stoppable-extension-entrypoint.ts') + const testEntrypoint = await import('./testdata/test-stoppable-extension-entrypoint') + testEntrypoint.disposedSessionIds.splice(0) + + const session = await host.start({ + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-stoppable-extension-entrypoint', + permissions: {}, + entrypoints: { + electron: entrypointPath, + }, + }, { cwd: '', runtime: 'electron' }) + + const reloaded = await host.reload(session.id) + + expect(reloaded.phase).toBe('ready') + expect(testEntrypoint.disposedSessionIds).toEqual([session.id]) + expect(host.listModules().map(module => module.id)).toEqual(['stoppable-extension-module']) + }) + + it('should resolve runtime-specific extension entrypoint with node fallback', async () => { const host = new FileSystemLoader() - const pluginDef = await host.loadPluginFor({ + const extension = await host.loadExtensionFor({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-extension', permissions: testPermissions, entrypoints: { - node: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + node: join(import.meta.dirname, 'testdata', 'test-define-extension-entrypoint.ts'), }, }, { cwd: '', runtime: 'node' }) - expect(pluginDef).toBeDefined() - expect(typeof pluginDef.init).toBe('function') + expect(extension).toBeDefined() + expect(extension.id).toBe('test-define-extension-entrypoint') + expect(typeof extension.setup).toBe('function') }) - it('should be able to handle test-error-plugin from manifest', async () => { + it('should reject entrypoints that do not export defineExtension', async () => { const host = new FileSystemLoader() - await expect(host.loadPluginFor({ + await expect(host.loadExtensionFor({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-extension', permissions: testPermissions, entrypoints: { - electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'), + electron: join(import.meta.dirname, 'testdata', 'test-invalid-extension-entrypoint.ts'), }, - }, { cwd: '', runtime: 'electron' })).rejects.toThrow('Test error plugin always throws an error during loading.') + }, { cwd: '', runtime: 'electron' })).rejects.toThrow('Failed to resolve extension module. The entrypoint must export defineExtension(...).') }) it('should resolve entrypoint by runtime then default then electron', () => { const host = new FileSystemLoader() const baseManifest = { apiVersion: 'v1' as const, - kind: 'manifest.plugin.airi.moeru.ai' as const, - name: 'test-plugin', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-extension', permissions: testPermissions, } @@ -138,19 +909,19 @@ describe('for FileSystemPluginHost', () => { } expect(host.resolveEntrypointFor(runtimeEntryManifest, { - cwd: '/tmp/plugin', + cwd: '/tmp/extension', runtime: 'node', - })).toBe('/tmp/plugin/node-entry.ts') + })).toBe('/tmp/extension/node-entry.ts') expect(host.resolveEntrypointFor(defaultFallbackManifest, { - cwd: '/tmp/plugin', + cwd: '/tmp/extension', runtime: 'node', - })).toBe('/tmp/plugin/default-entry.ts') + })).toBe('/tmp/extension/default-entry.ts') expect(host.resolveEntrypointFor(electronFallbackManifest, { - cwd: '/tmp/plugin', + cwd: '/tmp/extension', runtime: 'node', - })).toBe('/tmp/plugin/electron-entry.ts') + })).toBe('/tmp/extension/electron-entry.ts') }) it('should preserve absolute runtime entrypoints', () => { @@ -158,16 +929,16 @@ describe('for FileSystemPluginHost', () => { expect(host.resolveEntrypointFor({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-extension', permissions: testPermissions, entrypoints: { - node: '/opt/plugins/entry.ts', + node: '/opt/extensions/entry.ts', }, }, { - cwd: '/tmp/plugin', + cwd: '/tmp/extension', runtime: 'node', - })).toBe('/opt/plugins/entry.ts') + })).toBe('/opt/extensions/entry.ts') }) it('should throw deterministic error when no runtime entrypoint exists', () => { @@ -175,1457 +946,66 @@ describe('for FileSystemPluginHost', () => { expect(() => host.resolveEntrypointFor({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-extension', permissions: testPermissions, entrypoints: {}, - }, { runtime: 'node' })).toThrow('Plugin entrypoint is required for runtime `node`.') + }, { runtime: 'node' })).toThrow('Extension entrypoint is required for runtime `node`.') }) }) -describe('for PluginHost', () => { - const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' - const kitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits' - const toolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools' - const widgetKitBindingsResourceKey = 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings' - const customSessionApiPingEventName = 'proj-airi:plugin-sdk:apis:client:test-session-api:ping' - const testManifest = { - 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'), - }, - } - const dynamicApiManifest = { - ...testManifest, - permissions: { - ...testManifest.permissions, - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:update', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:bindings:withdraw', actions: ['invoke'] }, - { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, - ], - resources: [ - ...(testManifest.permissions.resources ?? []), - { key: kitRegistryResourceKey, actions: ['read'] }, - { key: toolRegistryResourceKey, actions: ['write'] }, - { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, - { key: widgetKitBindingsResourceKey, actions: ['read', 'write'] }, - ], - } satisfies ModulePermissionDeclaration, - } - const customSessionApiManifest = { - ...testManifest, - permissions: { - ...testManifest.permissions, - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: customSessionApiPingEventName, actions: ['invoke'] }, - ], - } satisfies ModulePermissionDeclaration, - } - const deniedKitReadManifest = { - ...testManifest, - permissions: { - ...testManifest.permissions, - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, - ], - } satisfies ModulePermissionDeclaration, - } +describe('for migrated extension testdata', () => { + it('starts the normal defineExtension fixture', async () => { + const host = new ExtensionHost() - function registerWidgetKit(host: PluginHost) { - return host.registerKit({ - kitId: 'kit.widget', - version: '1.0.0', - capabilities: [ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - { key: 'kit.widget.channel', actions: ['publish', 'subscribe'] }, - ], - runtimes: ['electron', 'web'], - }) - } + const session = await host.start({ + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-plugin', + permissions: {}, + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + }, + }, { cwd: '', runtime: 'electron' }) - it('should run plugin lifecycle to ready in-memory', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(testManifest, { cwd: '' }) - - await host.markConfigurationNeeded(session.id, 'manual-check') - - expect(session.phase).toBe('configuration-needed') - - await host.applyConfiguration(session.id, { - configId: `${session.identity.id}:manual`, - revision: 2, - schemaVersion: 1, - full: { mode: 'manual' }, - }) - - expect(session.phase).toBe('configured') - - const stopped = host.stop(session.id) - expect(stopped?.phase).toBe('stopped') - expect(host.getSession(session.id)).toBeUndefined() + expect(session.phase).toBe('ready') + expect(session.manifest.id).toBe('test-plugin') }) - it('should fail initialization when plugin init returns false', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) + it('surfaces setup failures from migrated defineExtension fixtures', async () => { + const host = new ExtensionHost() - const session = await host.load({ + await expect(host.start({ apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-plugin-no-connect', - permissions: testManifest.permissions, + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-plugin-no-connect', + permissions: {}, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-no-connect-plugin.ts'), }, - }, { cwd: '' }) - - await expect(host.init(session.id)).rejects.toThrow('Plugin initialization aborted by plugin: test-plugin-no-connect') - - expect(session.phase).toBe('stopped') - expect(host.getSession(session.id)).toBeUndefined() - }) - - it('should expose runtime-compatible kits through bound plugin apis', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - const widgetKit = registerWidgetKit(host) - host.registerKit({ - kitId: 'kit.node-only', - version: '1.0.0', - capabilities: [{ key: 'kit.node-only.module', actions: ['announce'] }], - runtimes: ['node'], - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - const kits = await session.apis.kits.list() - const capabilities = await session.apis.kits.getCapabilities('kit.widget') - - expect(kits).toEqual([widgetKit]) - expect(capabilities).toEqual(widgetKit.capabilities) - }) - - it('should expose plugin tool client bindings on the plugin session api surface', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - - expect(session.apis.tools).toBeDefined() - await expect(session.apis.tools.register({ - tool: { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - activation: { - keywords: ['chess'], - patterns: ['play.*chess'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true }), - })).resolves.toBeUndefined() - }) - - it('should let contributions install custom session api namespaces', async () => { - const installContribution = vi.fn() - const callCustomNamespace = vi.fn(({ ownerPluginId, message }: { ownerPluginId: string, message: string }) => { - return `${ownerPluginId}:${message}` - }) - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - contributions: [{ - install(context) { - installContribution() - context.registerSessionApi('testSessionApi', ({ session, assertPermission }) => ({ - async ping(message: string) { - assertPermission({ - area: 'apis', - action: 'invoke', - key: customSessionApiPingEventName, - }) - - return callCustomNamespace({ - ownerPluginId: session.ownerPluginId, - message, - }) - }, - })) - }, - }], - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(customSessionApiManifest, { cwd: '' }) - const testSessionApi = (session.apis as Record).testSessionApi as { - ping: (message: string) => Promise - } - - expect(installContribution).toHaveBeenCalledTimes(1) - expect(testSessionApi).toBeDefined() - await expect(testSessionApi.ping('hello')).resolves.toBe(`${session.identity.plugin.id}:hello`) - expect(callCustomNamespace).toHaveBeenCalledWith({ - ownerPluginId: session.identity.plugin.id, - message: 'hello', - }) - }) - - it('should register available plugin tools and expose serialized xsai schemas', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - - await session.apis.tools.register({ - tool: { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - activation: { - keywords: ['chess'], - patterns: ['play.*chess'], - }, - parameters: { - type: 'object', - properties: { - opening: { - type: 'string', - }, - }, - }, - }, - availability: () => true, - execute: async input => ({ ok: true, input }), - }) - - await session.apis.tools.register({ - tool: { - id: 'end_play_chess', - title: 'End Play Chess', - description: 'End chess.', - activation: { - keywords: ['end chess'], - patterns: ['end.*chess'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - availability: () => false, - execute: async () => ({ ok: true, ended: true }), - }) - await session.apis.tools.registerToolsetPrompt({ - id: 'chess-tools', - prompt: { - id: 'airi-plugin-game-chess.prompt', - title: 'Chess Plugin Guidance', - content: 'Do not pass fen or pgn when mode is "new".', - }, - }) - - await expect(host.listAvailableToolDescriptors()).resolves.toEqual([ - { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - activation: { - keywords: ['chess'], - patterns: ['play.*chess'], - }, - }, - ]) - await expect(host.listSerializedXsaiTools()).resolves.toEqual({ - prompts: [ - { - ownerPluginId: session.identity.plugin.id, - id: 'chess-tools', - prompt: { - id: 'airi-plugin-game-chess.prompt', - title: 'Chess Plugin Guidance', - content: 'Do not pass fen or pgn when mode is "new".', - }, - }, - ], - tools: [ - { - ownerPluginId: session.identity.plugin.id, - name: 'play_chess', - description: 'Open chess.', - parameters: { - type: 'object', - properties: { - opening: { - type: 'string', - }, - }, - }, - }, - ], - }) - await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', { opening: 'sicilian' })).resolves.toEqual({ - ok: true, - input: { opening: 'sicilian' }, - }) - await expect(host.invokeTool(session.identity.plugin.id, 'missing_tool', {})).rejects.toThrow( - `Plugin tool not found: ${session.identity.plugin.id}:missing_tool`, + }, { cwd: '', runtime: 'electron' })).rejects.toThrow( + 'Plugin initialization aborted by plugin: test-plugin-no-connect', ) }) - it('should hide and reject tools registered by stopped sessions', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - - await session.apis.tools.register({ - tool: { - id: 'play_chess', - title: 'Play Chess', - description: 'Open chess.', - activation: { - keywords: ['chess'], - patterns: ['play.*chess'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true }), - }) - - await expect(host.listAvailableToolDescriptors()).resolves.toEqual([ - expect.objectContaining({ id: 'play_chess' }), - ]) - - host.stop(session.id) - - await expect(host.listAvailableToolDescriptors()).resolves.toEqual([]) - await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] }) - await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', {})).rejects.toThrow( - `Plugin tool not found: ${session.identity.plugin.id}:play_chess`, - ) - }) - - it('should clean up sessions modules and tools when a session-ready hook throws during init', async () => { - const readyHookError = new Error('session-ready hook failed') - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - contributions: [{ - install(context) { - context.registerLifecycleHook('session-ready', () => { - throw readyHookError - }) - }, - }], - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.load(dynamicApiManifest, { cwd: '' }) - session.plugin = { - ...session.plugin, - setupModules: async ({ apis }) => { - await apis.tools.register({ - tool: { - id: 'ready_hook_tool', - title: 'Ready Hook Tool', - description: 'Registered before the ready hook throws.', - activation: { - keywords: ['ready'], - patterns: ['ready'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true }), - }) - - await apis.bindings.announce({ - moduleId: 'module-ready-hook-failure', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/ready-hook-failure' }, - }) - }, - } - - await expect(host.init(session.id)).rejects.toThrow('session-ready hook failed') - - expect(session.phase).toBe('stopped') - expect(host.getSession(session.id)).toBeUndefined() - expect(host.getBinding('module-ready-hook-failure')).toBeUndefined() - await expect(host.listAvailableToolDescriptors()).resolves.toEqual([]) - await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] }) - await expect(host.invokeTool(session.identity.plugin.id, 'ready_hook_tool', {})).rejects.toThrow( - `Plugin tool not found: ${session.identity.plugin.id}:ready_hook_tool`, - ) - }) - - it('should finish stop cleanup before rethrowing a session-stopped hook failure', async () => { - const stoppedHookError = new Error('session-stopped hook failed') - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - contributions: [{ - install(context) { - context.registerLifecycleHook('session-stopped', () => { - throw stoppedHookError - }) - }, - }], - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - await session.apis.tools.register({ - tool: { - id: 'stopped_hook_tool', - title: 'Stopped Hook Tool', - description: 'Should be cleaned up before stop rethrows.', - activation: { - keywords: ['stop'], - patterns: ['stop'], - }, - parameters: { - type: 'object', - properties: {}, - }, - }, - execute: async () => ({ ok: true }), - }) - await session.apis.bindings.announce({ - moduleId: 'module-stopped-hook-failure', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/stopped-hook-failure' }, - }) - - expect(() => host.stop(session.id)).toThrow('session-stopped hook failed') - - expect(session.phase).toBe('stopped') - expect(host.getSession(session.id)).toBeUndefined() - expect(host.getBinding('module-stopped-hook-failure')).toBeUndefined() - await expect(host.listAvailableToolDescriptors()).resolves.toEqual([]) - await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] }) - await expect(host.invokeTool(session.identity.plugin.id, 'stopped_hook_tool', {})).rejects.toThrow( - `Plugin tool not found: ${session.identity.plugin.id}:stopped_hook_tool`, - ) - }) - - it('should allow plugin to announce update activate and withdraw dynamic bindings through bound apis', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - expect('degrade' in session.apis.bindings).toBe(false) - expect(await session.apis.bindings.list()).toEqual([]) - - const announced = await session.apis.bindings.announce({ - moduleId: 'module-a', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets' }, - }) - const listedAfterAnnounce = await session.apis.bindings.list() - - expect(announced.moduleId).toBe('module-a') - expect(host.listBindings().some(item => item.moduleId === 'module-a')).toBe(true) - expect(listedAfterAnnounce).toEqual([ - expect.objectContaining({ - moduleId: 'module-a', - state: 'announced', - config: { route: '/widgets' }, - }), - ]) - - const activated = await session.apis.bindings.activate({ moduleId: 'module-a' }) - const listedAfterActivate = await session.apis.bindings.list() - const updated = await session.apis.bindings.update({ - moduleId: 'module-a', - config: { - route: '/widgets/main', - width: 420, - }, - }) - const listedAfterUpdate = await session.apis.bindings.list() - const withdrawn = await session.apis.bindings.withdraw({ moduleId: 'module-a' }) - const listedAfterWithdraw = await session.apis.bindings.list() - - expect(activated.state).toBe('active') - expect(listedAfterActivate).toEqual([ - expect.objectContaining({ - moduleId: 'module-a', - state: 'active', - }), - ]) - expect(updated.config).toEqual({ - route: '/widgets/main', - width: 420, - }) - expect(listedAfterUpdate).toEqual([ - expect.objectContaining({ - moduleId: 'module-a', - state: 'active', - config: { - route: '/widgets/main', - width: 420, - }, - }), - ]) - expect(withdrawn.state).toBe('withdrawn') - expect(listedAfterWithdraw).toEqual([ - expect.objectContaining({ - moduleId: 'module-a', - state: 'withdrawn', - config: { - route: '/widgets/main', - width: 420, - }, - }), - ]) - }) - - it('should let a test plugin consume injected kit and binding apis during init', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - registerWidgetKit(host) + it('runs the migrated injected kit fixture through ctx.modules and module.kits', async () => { + const host = new ExtensionHost() + const { testWidgetKit } = await import('./testdata/test-injected-host-apis-plugin') + host.registerKitApi(testWidgetKit) const session = await host.start({ - ...dynamicApiManifest, - name: 'test-plugin-injected-host-apis', + apiVersion: 'v1', + kind: 'manifest.extension.airi.moeru.ai' as const, + id: 'test-plugin-injected-host-apis', + permissions: { + apis: [{ key: testWidgetKit.id, actions: ['invoke'] }], + }, entrypoints: { electron: join(import.meta.dirname, 'testdata', 'test-injected-host-apis-plugin.ts'), }, - }, { cwd: '' }) + }, { cwd: '', runtime: 'electron' }) expect(session.phase).toBe('ready') - expect(host.listBindings()).toEqual([ - expect.objectContaining({ - moduleId: 'test-injected-host-apis-module', - ownerSessionId: session.id, - ownerPluginId: session.identity.plugin.id, - kitId: 'kit.widget', - kitModuleType: 'window', - state: 'active', - config: { - route: '/widgets/injected-host-apis', - observedKitIds: ['kit.widget'], - observedCapabilityKeys: ['kit.widget.channel', 'kit.widget.module'], - }, - }), - ]) - }) - - it('should reuse dynamic binding ids after stop cleanup and reload', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - await session.apis.bindings.announce({ - moduleId: 'module-reuse', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/reuse' }, - }) - - const reloaded = await host.reload(session.id, { cwd: '' }) - - expect(host.getBinding('module-reuse')).toBeUndefined() - expect(host.listBindings().some(item => item.moduleId === 'module-reuse')).toBe(false) - - const reused = await reloaded.apis.bindings.announce({ - moduleId: 'module-reuse', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/reuse-2' }, - }) - - expect(reused.ownerSessionId).toBe(reloaded.id) - expect(reused.config).toEqual({ route: '/widgets/reuse-2' }) - }) - - it('should isolate plugin-facing kit and module snapshots from plugin-side mutation', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(dynamicApiManifest, { cwd: '' }) - const listedKits = await session.apis.kits.list() - listedKits[0].kitId = 'kit.mutated' - listedKits[0].capabilities[0].actions.push('tampered') - listedKits[0].runtimes.push('node') - - const listedCapabilities = await session.apis.kits.getCapabilities('kit.widget') - listedCapabilities[0].actions.push('shadow-write') - - const announced = await session.apis.bindings.announce({ - moduleId: 'module-snapshot', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { route: '/widgets/snapshot' }, - }) - announced.config.route = '/widgets/tampered' - - const listedModules = await session.apis.bindings.list() - listedModules[0].config.route = '/widgets/list-tampered' - - expect(await session.apis.kits.list()).toEqual([ - expect.objectContaining({ - kitId: 'kit.widget', - capabilities: [ - expect.objectContaining({ - key: 'kit.widget.module', - actions: ['announce', 'activate', 'update', 'withdraw'], - }), - expect.objectContaining({ - key: 'kit.widget.channel', - actions: ['publish', 'subscribe'], - }), - ], - runtimes: ['electron', 'web'], - }), - ]) - expect(await session.apis.kits.getCapabilities('kit.widget')).toEqual([ - { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, - { key: 'kit.widget.channel', actions: ['publish', 'subscribe'] }, - ]) - expect(await session.apis.bindings.list()).toEqual([ - expect.objectContaining({ - moduleId: 'module-snapshot', - config: { route: '/widgets/snapshot' }, - }), - ]) - }) - - it('should deny new kit apis when resource read permission is missing', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - registerWidgetKit(host) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start(deniedKitReadManifest, { cwd: '' }) - - await expect(session.apis.kits.list()).rejects.toThrow('Permission denied: resources.read "proj-airi:plugin-sdk:resources:kits"') - }) - - it('should reject non in-memory transport for MVP', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'websocket', url: 'ws://localhost:3000' }, - }) - - await expect(host.start(testManifest, { cwd: '' })).rejects.toThrow('Only in-memory transport is currently supported by PluginHost alpha.') - }) - - it('should be able to expose setupModules', async () => { - const loader = new FileSystemLoader() - - const pluginDef = await loader.loadPluginFor({ - 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'), - }, - }, { cwd: '' }) - - const ctx = createContext() - const apis = createApis(ctx) - const onVitestCall = vi.fn() - ctx.on(defineEventa('vitest-call:init'), onVitestCall) - - await expect(pluginDef.init?.({ channels: { host: ctx }, apis })).resolves.not.toThrow() - expect(onVitestCall).toHaveBeenCalledTimes(1) - - defineInvokeHandler(ctx, protocolProviders.listProviders, async () => { - return [ - { name: 'provider1' }, - ] - }) - defineInvokeHandler(ctx, protocolCapabilityWait, async () => { - return { - key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', - state: 'ready', - updatedAt: Date.now(), - } - }) - - const onProviderListCall = vi.fn() - ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall) - await expect(pluginDef.setupModules?.({ channels: { host: ctx }, apis })).resolves.not.toThrow() - expect(onProviderListCall).toHaveBeenCalledTimes(1) - }) - - it('should wait for required capabilities before proceeding init', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const started = host.start(testManifest, { - cwd: '', - requiredCapabilities: ['cap:providers:list'], - capabilityWaitTimeoutMs: 2000, - }) - - await new Promise(resolve => setTimeout(resolve, 20)) - const loadingSession = host.listSessions().find(item => item.manifest.name === testManifest.name) - expect(loadingSession?.phase).toBe('waiting-deps') - - reportPluginCapability(host, { - key: 'cap:providers:list', - state: 'ready', - metadata: { source: 'test' }, - }) - const session = await started - expect(session.phase).toBe('ready') - }) - - it('should emit dependency wait details while waiting for required capabilities', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - const session = await host.load(testManifest, { cwd: '' }) - const statusEvents: Array<{ body?: Record }> = [] - session.channels.host.on(moduleStatus, (payload) => { - statusEvents.push(payload as unknown as { body?: Record }) - }) - - const started = host.init(session.id, { - requiredCapabilities: ['cap:custom'], - capabilityWaitTimeoutMs: 2000, - }) - - await new Promise(resolve => setTimeout(resolve, 20)) - - const waitingStatus = statusEvents.find((event) => { - const body = event.body - return body?.phase === 'preparing' && typeof body.reason === 'string' && body.reason.includes('Waiting for capabilities:') - }) - - expect(waitingStatus).toBeDefined() - expect(waitingStatus?.body).toMatchObject({ - phase: 'preparing', - details: { - lifecyclePhase: 'waiting-deps', - requiredCapabilities: ['cap:custom'], - unresolvedCapabilities: ['cap:custom'], - timeoutMs: 2000, - }, - }) - - reportPluginCapability(host, { - key: 'cap:custom', - state: 'ready', - metadata: { source: 'test' }, - }) - const initialized = await started - expect(initialized.phase).toBe('ready') - }) - - it('should fail when required capabilities timeout', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - await expect(host.start(testManifest, { - cwd: '', - requiredCapabilities: ['cap:missing'], - capabilityWaitTimeoutMs: 10, - })).rejects.toThrow('Capability `cap:missing` is not ready after 10ms.') - }) - - it('should support degraded and withdrawn capability states', () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - const announced = host.announceCapability('cap:dynamic', { source: 'announce' }) - expect(announced).toMatchObject({ - key: 'cap:dynamic', - state: 'announced', - metadata: { source: 'announce' }, - }) - - const degraded = host.markCapabilityDegraded('cap:dynamic', { reason: 'upstream-degraded' }) - expect(degraded).toMatchObject({ - key: 'cap:dynamic', - state: 'degraded', - metadata: { reason: 'upstream-degraded' }, - }) - expect(host.isCapabilityReady('cap:dynamic')).toBe(false) - - const withdrawn = host.withdrawCapability('cap:dynamic', { reason: 'disabled' }) - expect(withdrawn).toMatchObject({ - key: 'cap:dynamic', - state: 'withdrawn', - metadata: { reason: 'disabled' }, - }) - expect(host.isCapabilityReady('cap:dynamic')).toBe(false) - expect(host.listCapabilities()).toEqual(expect.arrayContaining([ - expect.objectContaining({ - key: 'cap:dynamic', - state: 'withdrawn', - }), - ])) - }) - - it('should resolve waits only when capability reaches ready state', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - host.markCapabilityDegraded('cap:unstable', { reason: 'booting' }) - const waiting = host.waitForCapability('cap:unstable', 2000) - - await new Promise(resolve => setTimeout(resolve, 20)) - host.withdrawCapability('cap:unstable', { reason: 'restarting' }) - - await new Promise(resolve => setTimeout(resolve, 20)) - host.markCapabilityReady('cap:unstable', { source: 'recovered' }) - - const resolved = await waiting - expect(resolved).toMatchObject({ - key: 'cap:unstable', - state: 'ready', - metadata: { source: 'recovered' }, - }) - }) - - it('should preserve previous cwd when reloading plugin', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.start({ - apiVersion: 'v1', - kind: 'manifest.plugin.airi.moeru.ai', - name: 'test-reload-relative-entrypoint', - permissions: testManifest.permissions, - entrypoints: { - electron: './test-normal-plugin.ts', - }, - }, { cwd: join(import.meta.dirname, 'testdata') }) - - const reloaded = await host.reload(session.id) - expect(reloaded.phase).toBe('ready') - }) - - it('should emit downgraded compatibility result when fallback versions overlap', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - protocolVersion: 'v2', - apiVersion: 'v2', - supportedProtocolVersions: ['v1'], - supportedApiVersions: ['v1'], - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.load(testManifest, { cwd: '' }) - const compatibilityEvents: Array<{ body?: Record }> = [] - session.channels.host.on(moduleCompatibilityResult, (payload) => { - compatibilityEvents.push(payload as unknown as { body?: Record }) - }) - - const initialized = await host.init(session.id, { - compatibility: { - supportedProtocolVersions: ['v1'], - supportedApiVersions: ['v1'], - }, - }) - - expect(initialized.phase).toBe('ready') - expect(compatibilityEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - protocolVersion: 'v1', - apiVersion: 'v1', - mode: 'downgraded', - }), - }), - ])) - }) - - it('should trim whitespace in supported compatibility versions before negotiating', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - protocolVersion: 'v2', - apiVersion: 'v2', - supportedProtocolVersions: [' v1 '], - supportedApiVersions: [' v1 '], - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const session = await host.load(testManifest, { cwd: '' }) - const compatibilityEvents: Array<{ body?: Record }> = [] - session.channels.host.on(moduleCompatibilityResult, (payload) => { - compatibilityEvents.push(payload as unknown as { body?: Record }) - }) - - const initialized = await host.init(session.id, { - compatibility: { - supportedProtocolVersions: [' v1 '], - supportedApiVersions: [' v1 '], - }, - }) - - expect(initialized.phase).toBe('ready') - expect(compatibilityEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - protocolVersion: 'v1', - apiVersion: 'v1', - mode: 'downgraded', - }), - }), - ])) - }) - - it('should reject initialization when compatibility has no overlap', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - protocolVersion: 'v2', - apiVersion: 'v2', - }) - - const session = await host.load(testManifest, { cwd: '' }) - - await expect(host.init(session.id, { - compatibility: { - supportedProtocolVersions: ['v9'], - supportedApiVersions: ['v9'], - }, - })).rejects.toThrow('Negotiation rejected:') - - expect(session.phase).toBe('stopped') - expect(host.getSession(session.id)).toBeUndefined() - }) - - it('should isolate module status events between plugin sessions', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const sessionOne = await host.start({ - ...testManifest, - name: 'test-plugin-session-one', - }, { cwd: '' }) - const sessionTwo = await host.start({ - ...testManifest, - name: 'test-plugin-session-two', - }, { cwd: '' }) - - const onSessionOneStatus = vi.fn() - const onSessionTwoStatus = vi.fn() - sessionOne.channels.host.on(moduleStatus, onSessionOneStatus) - sessionTwo.channels.host.on(moduleStatus, onSessionTwoStatus) - - host.markConfigurationNeeded(sessionOne.id, 'session-one-only') - - expect(onSessionOneStatus).toHaveBeenCalled() - expect(onSessionTwoStatus).not.toHaveBeenCalled() - }) - - it('should keep invoke handlers isolated per plugin context', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - - const sessionOne = await host.load({ - ...testManifest, - name: 'test-plugin-session-one', - }, { cwd: '' }) - const sessionTwo = await host.load({ - ...testManifest, - name: 'test-plugin-session-two', - }, { cwd: '' }) - - defineInvokeHandler(sessionOne.channels.host, protocolProviders.listProviders, async () => [{ name: 'provider:one' }]) - defineInvokeHandler(sessionTwo.channels.host, protocolProviders.listProviders, async () => [{ name: 'provider:two' }]) - - const invokeOne = defineInvoke(sessionOne.channels.host, protocolProviders.listProviders) - const invokeTwo = defineInvoke(sessionTwo.channels.host, protocolProviders.listProviders) - - await expect(invokeOne()).resolves.toEqual([{ name: 'provider:one' }]) - 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', - transport: { kind: 'in-memory' }, - }) - reportPluginCapability(host, { - key: providersCapability, - state: 'ready', - metadata: { source: 'test' }, - }) - - const sessionOne = await host.start({ - ...testManifest, - name: 'test-plugin-session-one', - }, { cwd: '' }) - expect(sessionOne.phase).toBe('ready') - - const sessionTwo = await host.load({ - ...testManifest, - name: 'test-plugin-session-two', - }, { cwd: '' }) - - const syncEvents: Array<{ body?: { modules?: Array<{ name: string }> } }> = [] - sessionTwo.channels.host.on(registryModulesSync, payload => syncEvents.push(payload)) - - const initialized = await host.init(sessionTwo.id) - expect(initialized.phase).toBe('ready') - - const moduleNames = syncEvents - .flatMap(event => event.body?.modules ?? []) - .map(module => module.name) - - expect(moduleNames).toContain('test-plugin-session-one') - expect(moduleNames).toContain('test-plugin-session-two') - }) - - it('should support runtime permission requests before granting deferred scopes', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - host.setResourceValue(providersCapability, [{ name: 'provider:runtime' }]) - - const session = await host.load({ - ...testManifest, - permissions: {}, - }, { cwd: '' }) - - const invokeProviders = defineInvoke(session.channels.host, protocolProviders.listProviders) - await expect(invokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) - - const declareEvents: Array<{ body?: Record }> = [] - const currentEvents: Array<{ body?: Record }> = [] - const requestEvents: Array<{ body?: Record }> = [] - const grantedEvents: Array<{ body?: Record }> = [] - - session.channels.host.on(modulePermissionsDeclare, payload => declareEvents.push(payload as unknown as { body?: Record })) - session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record })) - session.channels.host.on(modulePermissionsRequest, payload => requestEvents.push(payload as unknown as { body?: Record })) - session.channels.host.on(modulePermissionsGranted, payload => grantedEvents.push(payload as unknown as { body?: Record })) - - const runtimeRequest = { - apis: [ - { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, - ], - resources: [ - { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, - ], - } satisfies ModulePermissionDeclaration - - host.requestPermissions(session.id, runtimeRequest, 'Enable provider lookup') - - expect(host.getSession(session.id)?.permissions.requested).toEqual({ - apis: [ - { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, - ], - resources: [ - { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, - ], - capabilities: [], - processors: [], - pipelines: [], - }) - expect(requestEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - requested: expect.objectContaining({ - apis: [ - expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), - ], - resources: [ - expect.objectContaining({ key: providersCapability, actions: ['read'] }), - ], - }), - reason: 'Enable provider lookup', - }), - }), - ])) - expect(declareEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - source: 'runtime', - }), - }), - ])) - expect(currentEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - requested: expect.objectContaining({ - apis: [ - expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), - ], - }), - granted: expect.objectContaining({ - apis: [], - resources: [], - }), - }), - }), - ])) - - await expect(invokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) - - host.grantPermissions(session.id, { - apis: [ - { key: providersCapability, actions: ['invoke'] }, - ], - resources: [ - { key: providersCapability, actions: ['read'] }, - ], - }) - - expect(host.getSession(session.id)?.permissions.granted).toEqual({ - apis: [ - { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, - ], - resources: [ - { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, - ], - capabilities: [], - processors: [], - pipelines: [], - }) - expect(grantedEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - granted: expect.objectContaining({ - apis: [ - expect.objectContaining({ key: providersCapability, actions: ['invoke'] }), - ], - resources: [ - expect.objectContaining({ key: providersCapability, actions: ['read'] }), - ], - }), - }), - }), - ])) - - await expect(invokeProviders()).resolves.toEqual([{ name: 'provider:runtime' }]) - }) - - it('should only emit denied scopes that remain precisely representable after partial approval', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - permissionResolver: ({ requested }) => ({ - apis: [ - ...(requested.apis ?? []).filter(spec => spec.key.startsWith('proj-airi:plugin-sdk:')), - { key: 'plugin.api.users', actions: ['invoke'] }, - ], - resources: [ - ...(requested.resources ?? []).filter(spec => spec.key.startsWith('proj-airi:plugin-sdk:')), - { key: 'plugin.resource.settings', actions: ['read'] }, - ], - capabilities: requested.capabilities, - }), - }) - - const manifest = { - apiVersion: 'v1' as const, - kind: 'manifest.plugin.airi.moeru.ai' as const, - name: 'test-plugin-denied-partial', - permissions: { - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: 'plugin.api.users', actions: ['invoke', 'emit'], reason: 'Use selected user API actions' }, - ], - resources: [ - ...(testManifest.permissions.resources ?? []), - { key: 'plugin.resource.*', actions: ['read'], reason: 'Read plugin resources' }, - ], - capabilities: testManifest.permissions.capabilities, - } satisfies ModulePermissionDeclaration, - entrypoints: { - electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), - }, - } - - const session = await host.load(manifest, { cwd: '' }) - const deniedEvents: Array<{ body?: Record }> = [] - const currentEvents: Array<{ body?: Record }> = [] - session.channels.host.on(modulePermissionsDenied, payload => deniedEvents.push(payload as unknown as { body?: Record })) - session.channels.host.on(modulePermissionsCurrent, payload => currentEvents.push(payload as unknown as { body?: Record })) - - await host.init(session.id) - - expect(session.permissions.granted).toEqual({ - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: 'plugin.api.users', actions: ['invoke'], reason: 'Use selected user API actions' }, - ], - resources: [ - ...(testManifest.permissions.resources ?? []), - { key: 'plugin.resource.settings', actions: ['read'], reason: 'Read plugin resources' }, - ], - capabilities: testManifest.permissions.capabilities ?? [], - processors: [], - pipelines: [], - }) - - expect(deniedEvents).toEqual([ - expect.objectContaining({ - body: expect.objectContaining({ - denied: { - apis: [ - { key: 'plugin.api.users', actions: ['emit'], reason: 'Use selected user API actions' }, - ], - }, - }), - }), - ]) - expect(deniedEvents[0]?.body?.denied).not.toHaveProperty('resources') - expect(currentEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({ - body: expect.objectContaining({ - granted: { - apis: [ - ...(testManifest.permissions.apis ?? []), - { key: 'plugin.api.users', actions: ['invoke'], reason: 'Use selected user API actions' }, - ], - resources: [ - ...(testManifest.permissions.resources ?? []), - { key: 'plugin.resource.settings', actions: ['read'], reason: 'Read plugin resources' }, - ], - capabilities: testManifest.permissions.capabilities ?? [], - processors: [], - pipelines: [], - }, - }), - }), - ])) - }) - - it('should isolate runtime permission grants between concurrent same-name sessions', async () => { - const host = new PluginHost({ - runtime: 'electron', - transport: { kind: 'in-memory' }, - }) - host.setResourceValue(providersCapability, [{ name: 'provider:runtime' }]) - - const manifest = { - ...testManifest, - permissions: {}, - } - - const firstSession = await host.load(manifest, { cwd: '' }) - const secondSession = await host.load(manifest, { cwd: '' }) - - const firstInvokeProviders = defineInvoke(firstSession.channels.host, protocolProviders.listProviders) - const secondInvokeProviders = defineInvoke(secondSession.channels.host, protocolProviders.listProviders) - - const runtimeRequest = { - apis: [ - { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, - ], - resources: [ - { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, - ], - } satisfies ModulePermissionDeclaration - - host.requestPermissions(firstSession.id, runtimeRequest) - host.requestPermissions(secondSession.id, runtimeRequest) - - host.grantPermissions(firstSession.id, { - apis: [ - { key: providersCapability, actions: ['invoke'] }, - ], - resources: [ - { key: providersCapability, actions: ['read'] }, - ], - }) - - await expect(firstInvokeProviders()).resolves.toEqual([{ name: 'provider:runtime' }]) - await expect(secondInvokeProviders()).rejects.toThrow(`Permission denied: apis.invoke "${providersCapability}"`) - - expect(host.getSession(firstSession.id)?.permissions.granted).toEqual({ - apis: [ - { key: providersCapability, actions: ['invoke'], reason: 'Use providers API on demand' }, - ], - resources: [ - { key: providersCapability, actions: ['read'], reason: 'Read providers resource on demand' }, - ], - capabilities: [], - processors: [], - pipelines: [], - }) - expect(host.getSession(secondSession.id)?.permissions.granted).toEqual({ - apis: [], - resources: [], - capabilities: [], - processors: [], - pipelines: [], - }) + expect(host.listModules().map(module => module.id)).toEqual(['test-injected-host-apis-module']) }) }) diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 97bc62386..322f019ea 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -1,520 +1,63 @@ -import type { ActorRefFrom } from 'xstate' - -import type { createApis } from '../plugin/apis/client' +import type { + Extension, + ExtensionKitRegistry, + ExtensionModuleContext, + ExtensionSetupContext, + RegisterExtensionModuleInput, +} from '../extension/shared' +import type { KitAvailability, KitRef, KitUseResult } from '../kit' import type { AnnounceBindingInput, UpdateBindingInput } from '../plugin/apis/client/bindings' -import type { RegisterToolInput, RegisterToolsetPromptInput } from '../plugin/apis/client/tools' -import type { Plugin } from '../plugin/shared' import type { BindingRecord, KitCapabilityDescriptor, KitDescriptor } from './shared' import type { + ExtensionHostContribution, + ExtensionHostInstallContext, + ExtensionHostOptions, + ExtensionHostPermissionRequest, + ExtensionManifestV1, + ExtensionStartOptions, HostDataRecord, HostDataValue, - ManifestV1, - ModuleCompatibilityRequest, - ModuleConfigEnvelope, - ModuleIdentity, ModulePermissionDeclaration, ModulePermissionGrant, - PluginHostContribution, - PluginHostInstallContext, - PluginHostLifecycleEvent, - PluginHostLifecycleHook, - PluginHostOptions, - PluginHostPermissionRequest, - PluginHostSessionContext, - PluginLoadOptions, PluginRuntime, - PluginSessionApiFactory, - PluginSessionPhase, - PluginStartOptions, } from './shared/types' -import type { PluginTransport } from './transports' -import { cwd } from 'node:process' - -import { defineInvokeHandler } from '@moeru/eventa' -import { errorMessageFrom } from '@moeru/std' -import { - errorPermission, - moduleAnnounce, - moduleAuthenticate, - moduleAuthenticated, - moduleCompatibilityRequest, - moduleCompatibilityResult, - moduleConfigurationConfigured, - moduleConfigurationNeeded, - modulePermissionsCurrent, - modulePermissionsDeclare, - modulePermissionsDenied, - modulePermissionsGranted, - modulePermissionsRequest, - modulePrepared, - moduleStatus, - registryModulesSync, -} from '@proj-airi/plugin-protocol/types' -import { createActor, createMachine } from 'xstate' - -import { createApis as createBoundApis } from '../plugin/apis/client' +import { DisposableStore } from '../extension/disposable' +import { kitUseFailure } from '../kit' import { getKitBindingResourceKey, pluginBindingApiActivateEventName, pluginBindingApiAnnounceEventName, - pluginBindingApiListEventName, pluginBindingApiUpdateEventName, pluginBindingApiWithdrawEventName, - pluginBindingRegistryResourceKey, } from '../plugin/apis/client/bindings' -import { - pluginKitApiGetCapabilitiesEventName, - pluginKitApiListEventName, - pluginKitRegistryResourceKey, -} from '../plugin/apis/client/kits' -import { - pluginToolApiRegisterEventName, - pluginToolRegistryResourceKey, - -} from '../plugin/apis/client/tools' -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 { - BindingsRegistryService, DependencyService, + ExtensionSessionService, + KitApiBindingRegistryService, KitRegistryService, PermissionService, - PluginSessionService, ResourceService, - ToolRegistryService, } from './runtimes/shared' /** - * Plugin Host lifecycle overview (transport-aware): + * Extension host lifecycle overview. * - * - The host loads a plugin entrypoint (local or remote). - * - The host resolves a per-plugin transport (in-memory, worker, WebSocket, electron). - * - The host creates an Eventa context bound to that transport. - * - The host binds SDK APIs to the context and passes them into plugin.init. + * The host owns manifest validation, extension setup sessions, extension-level + * permission grants, and module cleanup. Extension code uses `setup(ctx)` as + * the common authoring entrypoint and requests host-installed kits through + * `ctx.kits`. Explicit modules are optional lifecycle and attribution scopes + * that can narrow kit usage through `module.kits`. * - * This design allows multiple plugins in one host without shared global channels. - * Each plugin instance has its own context and transport, so local and remote - * plugins share the same API surface while remaining isolated. + * Permission checks are intentionally two-layered: the extension grant is the + * package/session ceiling. Extension-scoped kit usage is checked against that + * ceiling directly; module-scoped kit usage is checked against the module grant + * derived from `extension grant intersection module request`. */ -/** - * One plugin could contribute multiple modules. - * - * For plugin itself, there are two ways to implement it, either local plugin, or remote plugin. - * Since we have @moeru/eventa as underlying event transmission, we can drive everything in event. - * - * It's ok that local plugin doesn't implement the remote protocol to handle the remote plugin - * RPC if doesn't wish for. Purely local UI manipulation or local resource registration is normal. - * - * In another word, we could implement the plugin in same eventa definition, while switching - * between two different transport. - * - * For local plugin, local context for in-memory transport will be used. - * For remote plugin, server-runtime for WebSocket based transport will be used. - * - * - * The procedure looks like this (regardless to the underlying transport since we will implement - * in both): - * - * 0. Channel Gateway sits on top of all channels - * 1. Connect to control plane channel (from plugin-sdk, or any language implementation will impl) - * 2. Authenticate with module:authenticate - * 3. Negotiate protocol/api compatibility before lifecycle work starts: - * 1. Plugin sends module:compatibility:request with: - * - plugin protocol version - * - plugin sdk api version - * - optional supported ranges for backward/forward compatibility - * 2. Plugin Host replies module:compatibility:result with: - * - accepted version tuple (protocol + api) - * - compatibility mode (exact, downgraded, rejected) - * - deterministic reason if rejected - * 3. If rejected, host MUST stop initialization for that plugin and emit module:status - * with incompatible-version details for Configurator visibility. - * 4. Plugin Host will send registry:modules:sync, this ensures the auto plugin / dependency discovery - * 5. Module will now announce itself to the entire system through module:announce - * 6. Module will now sync to Plugin Host that module now preparing, declaring its: - * 1. Dependencies to other plugins / modules - * 2. Initial Configuration (doesn't relate to capabilities) - * Note that for capabilities requires Database configuration, and perhaps Memory manipulation, - * plugin should orchestrate itself to contribute many capabilities / features, and the needed - * configurations and credentials should be requested and configured for each capabilities - * instead. - * 7. During this phase, if module failed to find the needed dependency, module:status will be emitted - * to allow the Plugin Host to surface errors or notice up to Configurator layer, to display the - * needed warning and status. - * - * It's ok for module to stay online / connected to channels. In this phase, module:announce - * could happen multiple times. Module is ok to listen to the sync events and decide whether to enter - * the next phases if needed. - * 8. During this phase, if plugin successfully configured itself and calculated / computed the possible - * contributing capabilities / features, it will emit module:prepared. - * 9. During this phase, if module requires more configuration to fill and enable in order to go next - * phase, it's ok, it will emit module:configuration:needed. - * 10. Module should now emit module:prepared. - * 11. Module should now emit module:configuration:needed, for telling the shape to Configurator. - * In between, for user side / Configurator side: - * - module:configuration:validate:request (static check, zod/valibot or programmatic checks) - * - module:configuration:validate:status (with parent event id) - * - module:configuration:validate:response - * - module:configuration:plan:request (actually dry-run, ensures anything during runtime works) - * - module:configuration:plan:status (with parent event id) - * - module:configuration:plan:response - * - module:configuration:commit - * - module:configuration:commit:status (with parent event id) - * 12. Module previously configured will get validate, plan, and commit automatically, if failed, status - * will surface to the Configurator side for further noticing to user. - * 13. Module should now emit module:configuration:configured. - * 14. Module should now be able to calculate / compute possible capabilities / features to be able to - * contribute to the system / Plugin Host, once calculated, module:contribute:capability:offer will - * be emitted in (length of) capabilities times. - * - * This means for 1 module that offers 5 capabilities, 5 * module:contribute:capability:offer will - * be emitted. - * 15. Next, module will now enter the capability / feature fill-in phase, during this phase, it's ok - * to say that the plugin is running but nothing gets contributed if none of them were configured. - * - * For any capabilities without further configuration and fill-in from Configurator and User side, - * it can be automatically activated now (which is next phase for module:contribute:capability:* - * events), module:contribute:capability:configuration:configured, - * module:contribute:capability:activated will be emitted. - * - * If further configuration and actions needed, module:contribute:capability:configuration:needed - * will be emitted. - * - * To configure the capabilities in sequence and correct order, - * - module:contribute:capability:configuration:validate:request (static check, zod/valibot or programmatic checks) - * - module:contribute:capability:configuration:validate:status (with parent event id) - * - module:contribute:capability:configuration:validate:response - * - module:contribute:capability:configuration:plan:request (actually dry-run, ensures anything during runtime works) - * - module:contribute:capability:configuration:plan:status (with parent event id) - * - module:contribute:capability:configuration:plan:response - * - module:contribute:capability:configuration:commit - * - module:contribute:capability:configuration:commit:status (with parent event id) - * similar to module:configuration are accepted. - * - * 16. No matter what happens, the module:status should emit with ready status now. - * 17. Any time the module need to re-calculate / re-compute, or wish to be re-configured, it's ok to - * emit module:status:change with needed phase to update, if need to rollback to announced phase, - * Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called. - */ - -type PluginLifecycleEvent - = | { type: 'SESSION_LOADED' } - | { type: 'START_AUTHENTICATION' } - | { type: 'AUTHENTICATED' } - | { type: 'ANNOUNCED' } - | { type: 'START_PREPARING' } - | { type: 'WAITING_DEPENDENCIES' } - | { type: 'PREPARED' } - | { type: 'CONFIGURATION_NEEDED' } - | { type: 'CONFIGURED' } - | { type: 'READY' } - | { type: 'SESSION_FAILED' } - | { type: 'REANNOUNCE' } - | { type: 'STOP' } - -const pluginLifecycleMachine = createMachine({ - id: 'plugin-lifecycle', - initial: 'loading', - states: { - 'loading': { - on: { - SESSION_LOADED: 'loaded', - SESSION_FAILED: 'failed', - }, - }, - 'loaded': { - on: { - START_AUTHENTICATION: 'authenticating', - STOP: 'stopped', - SESSION_FAILED: 'failed', - }, - }, - 'authenticating': { - on: { - AUTHENTICATED: 'authenticated', - SESSION_FAILED: 'failed', - }, - }, - 'authenticated': { - on: { - ANNOUNCED: 'announced', - SESSION_FAILED: 'failed', - }, - }, - 'announced': { - on: { - START_PREPARING: 'preparing', - CONFIGURATION_NEEDED: 'configuration-needed', - STOP: 'stopped', - SESSION_FAILED: 'failed', - }, - }, - 'preparing': { - on: { - WAITING_DEPENDENCIES: 'waiting-deps', - PREPARED: 'prepared', - SESSION_FAILED: 'failed', - }, - }, - 'waiting-deps': { - on: { - PREPARED: 'prepared', - SESSION_FAILED: 'failed', - }, - }, - 'prepared': { - on: { - CONFIGURATION_NEEDED: 'configuration-needed', - CONFIGURED: 'configured', - SESSION_FAILED: 'failed', - }, - }, - 'configuration-needed': { - on: { - CONFIGURED: 'configured', - SESSION_FAILED: 'failed', - }, - }, - 'configured': { - on: { - READY: 'ready', - SESSION_FAILED: 'failed', - }, - }, - 'ready': { - on: { - REANNOUNCE: 'announced', - CONFIGURATION_NEEDED: 'configuration-needed', - STOP: 'stopped', - SESSION_FAILED: 'failed', - }, - }, - 'failed': { - on: { - STOP: 'stopped', - }, - }, - 'stopped': { - type: 'final', - }, - }, -}) - -const lifecycleTransitionEvents: Record>> = { - 'loading': { loaded: 'SESSION_LOADED', failed: 'SESSION_FAILED' }, - 'loaded': { authenticating: 'START_AUTHENTICATION', stopped: 'STOP', failed: 'SESSION_FAILED' }, - 'authenticating': { authenticated: 'AUTHENTICATED', failed: 'SESSION_FAILED' }, - 'authenticated': { announced: 'ANNOUNCED', failed: 'SESSION_FAILED' }, - 'announced': { 'preparing': 'START_PREPARING', 'configuration-needed': 'CONFIGURATION_NEEDED', 'failed': 'SESSION_FAILED', 'stopped': 'STOP' }, - 'preparing': { 'waiting-deps': 'WAITING_DEPENDENCIES', 'prepared': 'PREPARED', 'failed': 'SESSION_FAILED' }, - 'waiting-deps': { prepared: 'PREPARED', failed: 'SESSION_FAILED' }, - 'prepared': { 'configuration-needed': 'CONFIGURATION_NEEDED', 'configured': 'CONFIGURED', 'failed': 'SESSION_FAILED' }, - 'configuration-needed': { configured: 'CONFIGURED', failed: 'SESSION_FAILED' }, - 'configured': { ready: 'READY', failed: 'SESSION_FAILED' }, - 'ready': { 'announced': 'REANNOUNCE', 'configuration-needed': 'CONFIGURATION_NEEDED', 'failed': 'SESSION_FAILED', 'stopped': 'STOP' }, - 'failed': { stopped: 'STOP' }, - 'stopped': {}, -} - -function assertTransition(session: PluginHostSession, to: PluginSessionPhase) { - const eventType = lifecycleTransitionEvents[session.phase][to] - if (!eventType) { - throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`) - } - - const event: PluginLifecycleEvent = { type: eventType } - const snapshot = session.lifecycle.getSnapshot() - if (!snapshot.can(event)) { - throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`) - } - - session.lifecycle.send(event) - session.phase = session.lifecycle.getSnapshot().value as PluginSessionPhase -} - -function markFailedTransition(session: PluginHostSession) { - const event: PluginLifecycleEvent = { type: 'SESSION_FAILED' } - const snapshot = session.lifecycle.getSnapshot() - if (snapshot.can(event)) { - session.lifecycle.send(event) - session.phase = session.lifecycle.getSnapshot().value as PluginSessionPhase - return - } - - if (session.phase !== 'failed') { - session.phase = 'failed' - } -} - -// TODO: Maybe support more complex version formats. -function normalizeVersionList(versions: string[]) { - return [...new Set(versions.map(version => version.trim()).filter(Boolean))] -} - -function resolveSupportedVersions(preferredVersion: string, supportedVersions?: string[]) { - return normalizeVersionList([preferredVersion, ...(supportedVersions ?? [])]) -} - -function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersions: string[], peerSupportedVersions?: string[]) { - const normalizedPreferredVersion = preferredVersion.trim() - const normalizedHostSupportedVersions = normalizeVersionList(hostSupportedVersions) - const normalizedPeerSupportedVersions = peerSupportedVersions && peerSupportedVersions.length > 0 - ? normalizeVersionList(peerSupportedVersions) - : undefined - - if (!normalizedPeerSupportedVersions?.length) { - if (normalizedHostSupportedVersions.includes(normalizedPreferredVersion)) { - return { - acceptedVersion: normalizedPreferredVersion, - exact: true, - } - } - - return { - exact: false, - reason: `Host does not support preferred version "${normalizedPreferredVersion}".`, - } - } - - if (normalizedPeerSupportedVersions.includes(normalizedPreferredVersion) - && normalizedHostSupportedVersions.includes(normalizedPreferredVersion)) { - return { - acceptedVersion: normalizedPreferredVersion, - exact: true, - } - } - - for (const version of normalizedHostSupportedVersions) { - if (normalizedPeerSupportedVersions.includes(version)) { - return { - acceptedVersion: version, - exact: false, - } - } - } - - return { - exact: false, - reason: `No overlapping supported versions. host=[${normalizedHostSupportedVersions.join(', ')}]; peer=[${normalizedPeerSupportedVersions.join(', ')}].`, - } -} - -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) - - 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 -} - -function matchPermissionKey(pattern: string, target: string) { - if (pattern === '*') { - return true - } - - if (pattern.endsWith('*')) { - return target.startsWith(pattern.slice(0, -1)) - } - - return pattern === target -} - -function getPermissionIntersectionKey(left: string, right: string) { - if (matchPermissionKey(left, right)) { - return right - } - - if (matchPermissionKey(right, left)) { - return left - } - - return undefined -} - -function filterDeniedPermissionScopes< - T extends { - key: string - actions: string[] - }, ->(requested: T[] | undefined, granted: T[] | undefined): T[] { - if (!requested?.length) { - return [] - } - - return requested.flatMap((requestedSpec) => { - const grantedActions = new Set() - let hasUnRepresentableOverlap = false - - for (const grantedSpec of granted ?? []) { - const intersectionKey = getPermissionIntersectionKey(requestedSpec.key, grantedSpec.key) - if (!intersectionKey) { - continue - } - - if (intersectionKey !== requestedSpec.key) { - // A narrower grant overlaps only part of the requested scope, such as: - // - requested `plugin.resource.*` - // - granted `plugin.resource.settings` - // - // The current declaration shape cannot express "everything except the granted subset", - // so reporting the whole requested scope as denied would contradict the granted/current - // snapshots. In that case we omit the denied entry rather than over-reporting it. - hasUnRepresentableOverlap = true - continue - } - - for (const action of grantedSpec.actions) { - if (requestedSpec.actions.includes(action)) { - grantedActions.add(action) - } - } - } - - const deniedActions = requestedSpec.actions.filter(action => !grantedActions.has(action)) - if (deniedActions.length === 0 || hasUnRepresentableOverlap) { - return [] - } - - return [{ - ...requestedSpec, - actions: deniedActions, - }] - }) -} class PermissionDeniedError extends Error { readonly details: { @@ -531,59 +74,41 @@ class PermissionDeniedError extends Error { } /** - * Describes the host-owned state tracked for one plugin session. - * - * Use when: - * - Reading session snapshots from `PluginHost` - * - Passing session state through host tests or orchestration code - * - * Expects: - * - `id` and `identity` stay stable for the lifetime of the session - * - * Returns: - * - The full session snapshot including transport, phase, bound APIs, and granted permissions + * Describes the host-owned state for one extension setup session. */ -export interface PluginHostSession { - /** Manifest used to load the plugin. */ - manifest: ManifestV1 - /** Loaded plugin hooks for the active session. */ - plugin: Plugin +export interface ExtensionSession { /** Unique host-generated session id. */ id: string - /** Monotonic index assigned when the session was created. */ - index: number - /** Working directory used to resolve relative entrypoints. */ - cwd: string - /** Protocol identity emitted on plugin lifecycle events. */ - identity: ModuleIdentity - /** Current host lifecycle phase for the session. */ - phase: PluginSessionPhase - /** XState actor that drives the session lifecycle transitions. */ - lifecycle: ActorRefFrom - /** Transport used by the session Eventa context. */ - transport: PluginTransport - /** Runtime used to load and run the plugin. */ - runtime: PluginRuntime - /** Host-owned Eventa channels injected into the plugin context. */ - channels: { - /** Control-plane Eventa context used for lifecycle and RPC traffic. */ - host: ReturnType + /** Extension identity and session metadata. */ + extension: { + id: string + version?: string + sessionId: string } - /** Bound plugin SDK APIs exposed to plugin code. */ - apis: PluginHostSessionApis - /** Requested and granted permissions for the session. */ + /** Manifest used to start this extension. */ + manifest: ExtensionManifestV1 + /** Working directory used to resolve relative manifest entrypoints. */ + cwd?: string + /** Runtime used to choose manifest entrypoints. */ + runtime?: PluginRuntime + /** Loaded extension definition. */ + entrypoint: Extension + /** Current extension setup phase. */ + phase: 'setting-up' | 'ready' | 'failed' | 'stopped' + /** Modules registered by this extension setup. */ + modules: Map + /** Requested and granted permissions for the extension session. */ permissions: { - /** Permissions requested by the manifest and runtime declarations. */ requested: ModulePermissionDeclaration - /** Permissions actually granted by the host. */ granted: ModulePermissionGrant - /** Permission snapshot revision number. */ revision: number } + /** Extension-session cleanup callbacks. */ + subscriptions: DisposableStore } /** - * Filters the binding list returned by `PluginHost.listBindings(...)`. + * Filters the binding list returned by `ExtensionHost.listBindings(...)`. * * Use when: * - Narrowing the host binding snapshot by owner session or kit @@ -594,8 +119,8 @@ export interface PluginHostSession { * Returns: * - Optional filter criteria for the in-memory binding registry */ -export interface PluginHostBindingListOptions { - /** Limit results to bindings owned by one plugin session. */ +export interface ExtensionHostBindingListOptions { + /** Limit results to bindings owned by one extension session. */ ownerSessionId?: string /** Limit results to bindings declared against one kit. */ kitId?: string @@ -604,9 +129,9 @@ export interface PluginHostBindingListOptions { type BoundAnnounceBindingInput = AnnounceBindingInput type BoundUpdateBindingInput = UpdateBindingInput -const builtInSessionApiNamespaces = new Set(['providers', 'kits', 'bindings', 'tools']) - -type PluginHostSessionApis = ReturnType & Record +interface ExtensionModuleResourceTracker { + bindingIds: Set +} function omitModuleId(input: BoundUpdateBindingInput) { return { @@ -656,66 +181,47 @@ function cloneBindingRecord(module: BindingRecord): } /** - * Orchestrates plugin loading, session lifecycle, bindings, tools, resources, and permissions. + * Orchestrates extension loading, setup sessions, bindings, resources, and permissions. * * Use when: - * - Running plugins inside the in-memory host implementation - * - Tests or applications need one place to load, initialize, start, stop, and query plugin sessions + * - Running extension entrypoints inside the in-memory host implementation + * - Tests or applications need one place to start, stop, reload, and query extension sessions * * Expects: - * - Plugins are loaded from manifest entrypoints through {@link FileSystemLoader} - * - Each session gets its own Eventa context, permission scope, and lifecycle actor + * - Extensions are loaded from manifest entrypoints through {@link FileSystemLoader} + * - Each session gets its own permission scope, module registry, and cleanup store * * Returns: - * - A host instance that exposes session management plus access to kits, bindings, tools, and capabilities + * - A host instance that exposes extension sessions plus access to kits, bindings, resources, and capabilities * * Call stack: * * caller - * -> {@link PluginHost.load} + * -> {@link ExtensionHost.start} * -> {@link FileSystemLoader.resolveEntrypointFor} - * -> {@link FileSystemLoader.loadPluginFor} - * -> {@link PluginHost.init} - * -> permission resolution + protocol negotiation - * -> binding of {@link createApis} into plugin context - * -> {@link PluginHost.start} - * -> {@link PluginHost.load} - * -> {@link PluginHost.init} + * -> {@link FileSystemLoader.loadExtensionFor} + * -> {@link ExtensionHost.startExtension} */ -export class PluginHost { +export class ExtensionHost { private readonly loader: FileSystemLoader - private readonly sessionService = new PluginSessionService() + private readonly extensionSessionService = new ExtensionSessionService() 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 dependencies = new DependencyService() private readonly kits = new KitRegistryService() - private readonly modules = new BindingsRegistryService() - private readonly tools = new ToolRegistryService() + private readonly kitApis = new Map>() + private readonly kitApiWatchers = new Map Promise>>() + private readonly modules = new KitApiBindingRegistryService() + private readonly extensionModuleResources = new Map() private readonly permissions = new PermissionService() - private readonly permissionResolver?: PluginHostOptions['permissionResolver'] + private readonly permissionResolver?: ExtensionHostOptions['permissionResolver'] private readonly persistedPermissionGrants = new Map() private readonly resources = new ResourceService() - private readonly sessionApiFactories = new Map() - private readonly lifecycleHooks: Record = { - 'session-loaded': [], - 'session-ready': [], - 'session-stopped': [], - } - private readonly installContext: PluginHostInstallContext + private readonly installContext: ExtensionHostInstallContext - constructor(options: PluginHostOptions = {}) { + constructor(options: ExtensionHostOptions = {}) { this.loader = new FileSystemLoader() this.runtime = options.runtime ?? 'electron' - this.transport = options.transport ?? { kind: 'in-memory' } - this.protocolVersion = options.protocolVersion ?? 'v1' - 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' }) @@ -726,73 +232,295 @@ export class PluginHost { } } - private getPermissionScopeKey(session: PluginHostSession) { - return session.id + async startExtension( + extension: Extension, + options: { manifest: ExtensionManifestV1, cwd?: string, runtime?: PluginRuntime }, + ) { + if (extension.id !== options.manifest.id) { + throw new Error(`Extension entrypoint id \`${extension.id}\` must match manifest id \`${options.manifest.id}\`.`) + } + + const sessionIdentity = this.extensionSessionService.nextSessionIdentity() + const extensionIdentity = { + id: extension.id, + version: extension.version, + sessionId: sessionIdentity.sessionId, + } + const persistedGrant = this.persistedPermissionGrants.get(extension.id) + const resolvedGrant = await this.permissionResolver?.({ + identity: extensionIdentity, + manifest: options.manifest, + requested: options.manifest.permissions, + persisted: persistedGrant, + }) ?? options.manifest.permissions + const permissionSnapshot = this.permissions.initialize(sessionIdentity.sessionId, options.manifest.permissions, { + grant: resolvedGrant, + persisted: this.permissionResolver ? undefined : persistedGrant, + }) + this.persistedPermissionGrants.set(extension.id, permissionSnapshot.granted) + const subscriptions = new DisposableStore() + const session: ExtensionSession = { + id: sessionIdentity.sessionId, + extension: extensionIdentity, + manifest: options.manifest, + cwd: options.cwd, + runtime: options.runtime, + entrypoint: extension, + phase: 'setting-up', + modules: new Map(), + permissions: { + requested: permissionSnapshot.requested, + granted: permissionSnapshot.granted, + revision: permissionSnapshot.revision, + }, + subscriptions, + } + + this.extensionSessionService.register(session) + + const ctx: ExtensionSetupContext = { + extension: session.extension, + kits: this.createExtensionKitRegistry(session), + subscriptions, + modules: { + register: async (input: RegisterExtensionModuleInput) => { + if (session.modules.has(input.id)) { + throw new Error(`Extension module \`${input.id}\` is already registered for session ${session.id}.`) + } + + const moduleSubscriptions = new DisposableStore() + const permissions = this.permissions.intersectGrant( + session.permissions.granted, + input.permissions ?? session.permissions.granted, + ) + const module: ExtensionModuleContext = { + id: input.id, + identity: { + id: input.id, + extension: session.extension, + labels: input.labels, + }, + permissions, + kits: this.createModuleKitRegistry(session, moduleSubscriptions, input.id), + subscriptions: moduleSubscriptions, + dispose: async () => { + await this.cleanupExtensionModuleResources(session, input.id) + await moduleSubscriptions.dispose() + session.modules.delete(input.id) + }, + } + session.modules.set(module.id, module) + return module + }, + }, + } + + try { + await extension.setup(ctx) + session.phase = 'ready' + return session + } + catch (error) { + session.phase = 'failed' + await this.cleanupExtensionSession(session) + throw error + } } - private assertPermission( - session: PluginHostSession, - input: PluginHostPermissionRequest, - ) { - const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key) - if (allowed) { + listModules() { + return this.extensionSessionService + .list() + .flatMap(session => [...session.modules.values()]) + } + + registerKitApi(kit: KitRef) { + this.kitApis.set(kit.id, kit as KitRef) + void this.notifyKitApiWatchers(kit.id) + return kit + } + + unregisterKitApi(kitId: string) { + const deleted = this.kitApis.delete(kitId) + void this.notifyKitApiWatchers(kitId) + return deleted + } + + private async cleanupExtensionSessionModules(session: ExtensionSession) { + for (const module of [...session.modules.values()].reverse()) { + await module.dispose() + } + session.modules.clear() + } + + private getExtensionModuleResourceKey(sessionId: string, moduleId: string) { + return `${sessionId}:${moduleId}` + } + + private getOrCreateExtensionModuleResourceTracker(sessionId: string, moduleId: string) { + const key = this.getExtensionModuleResourceKey(sessionId, moduleId) + let resources = this.extensionModuleResources.get(key) + if (!resources) { + resources = { + bindingIds: new Set(), + } + this.extensionModuleResources.set(key, resources) + } + + return resources + } + + private async cleanupExtensionModuleResources(session: ExtensionSession, moduleId: string) { + const key = this.getExtensionModuleResourceKey(session.id, moduleId) + const resources = this.extensionModuleResources.get(key) + if (!resources) { return } - const error = new PermissionDeniedError({ + for (const bindingId of resources.bindingIds) { + const binding = this.modules.get(bindingId) + if (!binding) { + continue + } + + if (binding.state !== 'withdrawn') { + this.modules.withdraw(session.id, session.extension.id, bindingId) + } + this.modules.unbind(session.id, session.extension.id, bindingId) + } + + this.extensionModuleResources.delete(key) + } + + private async notifyKitApiWatchers(kitId: string) { + const watchers = this.kitApiWatchers.get(kitId) + if (!watchers?.size) { + return + } + + for (const watcher of watchers) { + await watcher() + } + } + + private resolveKitApi( + session: ExtensionSession, + kit: KitRef, + subscriptions: DisposableStore, + moduleId?: string, + ): KitUseResult { + const registered = this.kitApis.get(kit.id) as KitRef | undefined + if (!registered) { + return kitUseFailure(kit, 'missing-kit') + } + + const grant = moduleId + ? session.modules.get(moduleId)?.permissions + : session.permissions.granted + + if (!grant || !this.permissions.grantAllows(grant, 'apis', 'invoke', kit.id)) { + return kitUseFailure(kit, 'permission-denied') + } + + return { + ok: true, + client: registered.createClient({ + extensionId: session.extension.id, + sessionId: session.id, + moduleId, + subscriptions, + }), + } + } + + private createKitRegistry(session: ExtensionSession, subscriptions: DisposableStore, moduleId?: string): ExtensionKitRegistry { + return { + use: async (kit: KitRef) => { + const result = this.resolveKitApi(session, kit, subscriptions, moduleId) + if (result.ok) { + return result.client + } + const failure = result as Extract, { ok: false }> + throw failure.error + }, + tryUse: async (kit: KitRef) => { + return this.resolveKitApi(session, kit, subscriptions, moduleId) + }, + watch: (kit: KitRef, callback: (availability: KitAvailability) => void | Promise) => { + const watchers = this.kitApiWatchers.get(kit.id) ?? new Set() + let disposed = false + const watcher = async () => { + if (disposed) { + return + } + + const result = this.resolveKitApi(session, kit, subscriptions, moduleId) + if (result.ok) { + await callback({ available: true, kit, client: result.client }) + return + } + + const failure = result as Extract, { ok: false }> + await callback({ available: false, kit, reason: failure.reason, error: failure.error }) + } + watchers.add(watcher) + this.kitApiWatchers.set(kit.id, watchers) + void watcher() + return subscriptions.add({ + dispose: () => { + if (disposed) { + return + } + + disposed = true + watchers.delete(watcher) + if (watchers.size === 0) { + this.kitApiWatchers.delete(kit.id) + } + }, + }) + }, + } + } + + private createExtensionKitRegistry(session: ExtensionSession): ExtensionKitRegistry { + return this.createKitRegistry(session, session.subscriptions) + } + + private createModuleKitRegistry(session: ExtensionSession, subscriptions: DisposableStore, moduleId: string): ExtensionModuleContext['kits'] { + return this.createKitRegistry(session, subscriptions, moduleId) + } + + private assertExtensionPermission( + session: ExtensionSession, + input: ExtensionHostPermissionRequest, + moduleId?: string, + ) { + const grant = moduleId + ? session.modules.get(moduleId)?.permissions + : session.permissions.granted + + if (grant && this.permissions.grantAllows(grant, input.area, input.action, input.key)) { + return + } + + throw 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 } - private getSessionOrThrow(sessionId: string) { - const session = this.sessionService.get(sessionId) + private getExtensionSessionOrThrow(sessionId: string) { + const session = this.extensionSessionService.get(sessionId) if (!session) { - throw new Error(`Unknown plugin session: ${sessionId}`) + throw new Error(`Unknown extension session: ${sessionId}`) } return session } - private createSessionContext(session: PluginHostSession): PluginHostSessionContext { + private createInstallContext(): ExtensionHostInstallContext { return { - sessionId: session.id, - ownerPluginId: session.identity.plugin.id, - runtime: session.runtime, - } - } - - private createInstallContext(): PluginHostInstallContext { - return { - registerSessionApi: (namespace, factory) => { - if (builtInSessionApiNamespaces.has(namespace)) { - throw new Error(`Session API namespace \`${namespace}\` is reserved by PluginHost.`) - } - - const currentFactory = this.sessionApiFactories.get(namespace) - if (currentFactory && currentFactory !== factory) { - throw new Error(`Duplicate session API namespace registration for \`${namespace}\`.`) - } - - this.sessionApiFactories.set(namespace, factory) - }, - registerLifecycleHook: (event, hook) => { - this.lifecycleHooks[event].push(hook) - }, registerKit: kit => this.registerKit(kit), unregisterKit: kitId => this.unregisterKit(kitId), setResourceResolver: (key, resolver) => this.setResourceResolver(key, resolver), @@ -812,128 +540,20 @@ export class PluginHost { } } - private installContribution(contribution: PluginHostContribution) { + private installContribution(contribution: ExtensionHostContribution) { contribution.install(this.installContext) } - private createSessionApis( - session: PluginHostSession, - hostChannel: ReturnType, - ): PluginHostSessionApis { - const baseApis = createBoundApis(hostChannel, { - kits: { - list: () => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: pluginKitApiListEventName, - }) - this.assertPermission(session, { - area: 'resources', - action: 'read', - key: pluginKitRegistryResourceKey, - }) - - return this.listKits(session.runtime) - }, - getCapabilities: (kitId) => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: pluginKitApiGetCapabilitiesEventName, - }) - this.assertPermission(session, { - area: 'resources', - action: 'read', - key: pluginKitRegistryResourceKey, - }) - this.assertKitAvailableForSession(session, kitId) - - return this.getKitCapabilities(kitId) - }, - }, - bindings: { - list: () => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: pluginBindingApiListEventName, - }) - this.assertPermission(session, { - area: 'resources', - action: 'read', - key: pluginBindingRegistryResourceKey, - }) - - return this.listBindings({ ownerSessionId: session.id }) - }, - announce: input => this.announceBinding(session.id, input), - activate: input => this.activateBinding(session.id, input.moduleId), - update: input => this.updateBinding(session.id, input.moduleId, input), - withdraw: input => this.withdrawBinding(session.id, input.moduleId), - }, - tools: { - register: input => this.registerTool(session.id, input), - registerToolsetPrompt: input => this.registerToolsetPrompt(session.id, input), - }, - }) - - const contributionApis = Object.fromEntries( - [...this.sessionApiFactories.entries()].map(([namespace, factory]) => [ - namespace, - factory({ - host: this.installContext, - session: this.createSessionContext(session), - assertPermission: input => this.assertPermission(session, input), - }), - ]), - ) - - return { - ...baseApis, - ...contributionApis, - } - } - - private runLifecycleHooks(event: PluginHostLifecycleEvent, session: PluginHostSession) { - for (const hook of this.lifecycleHooks[event]) { - hook({ - host: this.installContext, - session: this.createSessionContext(session), - manifest: session.manifest, - }) - } - } - - private cleanupSession(session: PluginHostSession) { - let lifecycleHookError: unknown - - if (session.phase !== 'stopped') { - const canStop = session.lifecycle.getSnapshot().can({ type: 'STOP' }) - if (canStop) { - assertTransition(session, 'stopped') - } - else { - session.phase = 'stopped' - } - } + private async cleanupExtensionSession(session: ExtensionSession) { + session.phase = 'stopped' for (const module of this.modules.listByOwner(session.id)) { - this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId) - this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId) + this.modules.withdraw(session.id, session.extension.id, module.moduleId) + this.modules.unbind(session.id, session.extension.id, module.moduleId) } - - try { - this.runLifecycleHooks('session-stopped', session) - } - catch (error) { - lifecycleHookError = error - } - - session.lifecycle.stop() - this.sessionService.remove(session.id) - - return lifecycleHookError + await this.cleanupExtensionSessionModules(session) + await session.subscriptions.dispose() + this.extensionSessionService.remove(session.id) } private getModuleOrThrow(moduleId: string) { @@ -945,25 +565,25 @@ export class PluginHost { return module } - private assertKitAvailableForSession(session: PluginHostSession, kitId: string) { + private assertKitAvailableForRuntime(kitId: string, runtime: PluginRuntime) { const kit = this.kits.get(kitId) if (!kit) { throw new Error(`Kit \`${kitId}\` is not registered.`) } - if (!kit.runtimes.includes(session.runtime)) { - throw new Error(`Kit \`${kitId}\` is not available for runtime \`${session.runtime}\`.`) + if (!kit.runtimes.includes(runtime)) { + throw new Error(`Kit \`${kitId}\` is not available for runtime \`${runtime}\`.`) } return kit } listSessions() { - return this.sessionService.list() + return this.extensionSessionService.list() } getSession(sessionId: string) { - return this.sessionService.get(sessionId) + return this.extensionSessionService.get(sessionId) } registerKit(kit: KitDescriptor) { @@ -1009,7 +629,7 @@ export class PluginHost { return cloneBindingRecord(module) } - listBindings(options: PluginHostBindingListOptions = {}) { + listBindings(options: ExtensionHostBindingListOptions = {}) { return this.modules.list().filter((module) => { if (options.ownerSessionId && module.ownerSessionId !== options.ownerSessionId) { return false @@ -1023,31 +643,19 @@ export class PluginHost { }).map(module => cloneBindingRecord(module)) } - async listAvailableToolDescriptors() { - return await this.tools.listAvailableDescriptors() - } - - async listSerializedXsaiTools() { - return await this.tools.listSerializedXsaiTools() - } - - async invokeTool(ownerPluginId: string, toolId: string, input: unknown) { - return await this.tools.invoke(ownerPluginId, toolId, input) - } - announceBinding( sessionId: string, input: BoundAnnounceBindingInput, ): BindingRecord { - const session = this.getSessionOrThrow(sessionId) - const kit = this.assertKitAvailableForSession(session, input.kitId) + const session = this.getExtensionSessionOrThrow(sessionId) + const kit = this.assertKitAvailableForRuntime(input.kitId, session.runtime ?? this.runtime) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'apis', action: 'invoke', key: pluginBindingApiAnnounceEventName, }) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', key: getKitBindingResourceKey(kit.kitId), @@ -1057,28 +665,28 @@ export class PluginHost { return cloneBindingRecord(this.modules.bind({ ...input, ownerSessionId: session.id, - ownerPluginId: session.identity.plugin.id, - runtime: session.runtime, + ownerPluginId: session.extension.id, + runtime: session.runtime ?? this.runtime, }) as BindingRecord) } activateBinding(sessionId: string, moduleId: string) { - const session = this.getSessionOrThrow(sessionId) + const session = this.getExtensionSessionOrThrow(sessionId) const module = this.getModuleOrThrow(moduleId) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'apis', action: 'invoke', key: pluginBindingApiActivateEventName, }) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', key: getKitBindingResourceKey(module.kitId), reason: `Module activation requires write access to kit \`${module.kitId}\`.`, }) - return cloneBindingRecord(this.modules.activate(session.id, session.identity.plugin.id, moduleId)) + return cloneBindingRecord(this.modules.activate(session.id, session.extension.id, moduleId)) } updateBinding( @@ -1086,15 +694,15 @@ export class PluginHost { moduleId: string, patch: UpdateBindingInput | Omit, 'moduleId'>, ) { - const session = this.getSessionOrThrow(sessionId) + const session = this.getExtensionSessionOrThrow(sessionId) const module = this.getModuleOrThrow(moduleId) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'apis', action: 'invoke', key: pluginBindingApiUpdateEventName, }) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', key: getKitBindingResourceKey(module.kitId), @@ -1102,590 +710,85 @@ export class PluginHost { }) const normalizedPatch = 'moduleId' in patch ? omitModuleId(patch) : patch - return cloneBindingRecord(this.modules.update(session.id, session.identity.plugin.id, moduleId, normalizedPatch)) + return cloneBindingRecord(this.modules.update(session.id, session.extension.id, moduleId, normalizedPatch)) } degradeBinding(sessionId: string, moduleId: string) { - const session = this.getSessionOrThrow(sessionId) + const session = this.getExtensionSessionOrThrow(sessionId) const module = this.getModuleOrThrow(moduleId) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', key: getKitBindingResourceKey(module.kitId), reason: `Module degradation requires write access to kit \`${module.kitId}\`.`, }) - return cloneBindingRecord(this.modules.degrade(session.id, session.identity.plugin.id, moduleId)) + return cloneBindingRecord(this.modules.degrade(session.id, session.extension.id, moduleId)) } withdrawBinding(sessionId: string, moduleId: string) { - const session = this.getSessionOrThrow(sessionId) + const session = this.getExtensionSessionOrThrow(sessionId) const module = this.getModuleOrThrow(moduleId) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'apis', action: 'invoke', key: pluginBindingApiWithdrawEventName, }) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', key: getKitBindingResourceKey(module.kitId), reason: `Module withdrawal requires write access to kit \`${module.kitId}\`.`, }) - return cloneBindingRecord(this.modules.withdraw(session.id, session.identity.plugin.id, moduleId)) + return cloneBindingRecord(this.modules.withdraw(session.id, session.extension.id, moduleId)) } - registerTool(sessionId: string, input: RegisterToolInput) { - const session = this.getSessionOrThrow(sessionId) + bindExtensionKitModule( + sessionId: string, + input: BoundAnnounceBindingInput, + permissionModuleId?: string, + ): BindingRecord { + const session = this.getExtensionSessionOrThrow(sessionId) + const kit = this.assertKitAvailableForRuntime(input.kitId, this.runtime) - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: pluginToolApiRegisterEventName, - }) - this.assertPermission(session, { + this.assertExtensionPermission(session, { area: 'resources', action: 'write', - key: pluginToolRegistryResourceKey, - }) + key: getKitBindingResourceKey(kit.kitId), + reason: `Module announce requires write access to kit \`${kit.kitId}\`.`, + }, permissionModuleId) - this.tools.register({ + const binding = cloneBindingRecord(this.modules.bind({ + ...input, ownerSessionId: session.id, - ownerPluginId: session.identity.plugin.id, - tool: { - ...input.tool, - activation: { - keywords: [...input.tool.activation.keywords], - patterns: [...input.tool.activation.patterns], - }, - parameters: cloneHostDataRecord(input.tool.parameters), - }, - availability: async () => { - if (!this.getSession(session.id)) { - return false - } + ownerPluginId: session.extension.id, + runtime: this.runtime, + }) as BindingRecord) - return await input.availability?.() ?? true - }, - execute: async (toolInput) => { - if (!this.getSession(session.id)) { - throw new Error(`Plugin tool not found: ${session.identity.plugin.id}:${input.tool.id}`) - } + if (permissionModuleId) { + this.getOrCreateExtensionModuleResourceTracker(session.id, permissionModuleId).bindingIds.add(binding.moduleId) + } - return await input.execute(toolInput) - }, - }) + return binding } - registerToolsetPrompt(sessionId: string, input: RegisterToolsetPromptInput) { - const session = this.getSessionOrThrow(sessionId) - - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: pluginToolApiRegisterEventName, - }) - this.assertPermission(session, { - area: 'resources', - action: 'write', - key: pluginToolRegistryResourceKey, - }) - - this.tools.registerToolsetPrompt({ - ownerSessionId: session.id, - ownerPluginId: session.identity.plugin.id, - toolset: structuredClone(input), - availability: () => Boolean(this.getSession(session.id)), - }) - } - - async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise { - // Step 0 (channel gateway preparation): resolve runtime and transport for this plugin. - const runtime = options.runtime ?? this.runtime - const sessionCwd = options.cwd ?? cwd() // Explicitly assign the default CWD. - const transport = this.transport - - // TODO: implement other transports and runtime bindings. - // alpha scope guard: - // we intentionally fail fast for non in-memory transports while iterating on lifecycle design. - if (transport.kind !== 'in-memory') { - throw new Error(`Only in-memory transport is currently supported by PluginHost alpha. Got: ${transport.kind}`) - } - - // 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() - - const permissionSnapshot = this.permissions.initialize( - id, - manifest.permissions, - { - persisted: this.persistedPermissionGrants.get(identity.plugin.id), - }, - ) - - const session: PluginHostSession = { - manifest, - plugin: {}, - id, - index: sessionIndex, - cwd: sessionCwd, - identity, - phase: lifecycle.getSnapshot().value as PluginSessionPhase, - lifecycle, - transport, - runtime, - channels: { - host: hostChannel, - }, - apis: {} as PluginHostSessionApis, - permissions: { - requested: permissionSnapshot.requested, - granted: permissionSnapshot.granted, - revision: permissionSnapshot.revision, - }, - } - session.apis = this.createSessionApis(session, hostChannel) - - defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: protocolCapabilityWaitEventName, - }) - this.assertPermission(session, { - area: 'capabilities', - action: 'wait', - key: payload.key, - }) - return await this.waitForCapability(payload.key, payload?.timeoutMs) - }) - defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: protocolCapabilitySnapshotEventName, - }) - this.assertPermission(session, { - area: 'capabilities', - action: 'snapshot', - key: '*', - }) - return this.listCapabilities() - }) - defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => { - this.assertPermission(session, { - area: 'apis', - action: 'invoke', - key: protocolListProvidersEventName, - }) - this.assertPermission(session, { - area: 'resources', - action: 'read', - key: protocolListProvidersEventName, - }) - return await this.resources.get>(protocolListProvidersEventName, []) ?? [] - }) - - // Register session before loading so failure paths still have observable state. - this.sessionService.register(session) - - try { - // Load plugin module from manifest-selected runtime entrypoint. - // This is where malformed entrypoints or import errors surface. - session.plugin = await this.loader.loadPluginFor(manifest, { - cwd: sessionCwd, - runtime, - }) - - // Assert lifecycle progression (`loading` -> `loaded`) to keep transition rules explicit. - // This prevents accidental phase drift if the method evolves later. - assertTransition(session, 'loaded') - this.runLifecycleHooks('session-loaded', session) - return session - } - catch (error) { - // Load failure is terminal for this session (`loading` -> `failed`). - // Emit status so Configurator/observers can show deterministic diagnostics. - markFailedTransition(session) - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'failed', - reason: errorMessageFrom(error) ?? 'Failed to load plugin.', - }) - - throw error - } - } - - async init(sessionId: string, options: PluginStartOptions = {}): Promise { - // `init` starts at procedure step 2 (authenticate) and drives lifecycle to ready. - const session = this.sessionService.get(sessionId) - if (!session) { - throw new Error(`Unable to initialize plugin session: ${sessionId}`) - } - - // Safety gate: initialization can only begin from a successfully loaded plugin. - if (session.phase !== 'loaded') { - throw new Error(`Session ${sessionId} cannot initialize from phase ${session.phase}. Expected loaded.`) - } - - try { - let preparedEmitted = false - - // Step 2: authenticate module against host control plane. - assertTransition(session, 'authenticating') - session.channels.host.emit(moduleAuthenticate, { - token: `${session.id}:${session.identity.id}`, - }) - - // Mark local lifecycle after authentication handshake. - assertTransition(session, 'authenticated') - session.channels.host.emit(moduleAuthenticated, { authenticated: true }) - - // Step 3: protocol/api compatibility negotiation. - const compatibilityRequest: ModuleCompatibilityRequest = { - protocolVersion: this.protocolVersion, - apiVersion: this.apiVersion, - supportedProtocolVersions: options.compatibility?.supportedProtocolVersions, - supportedApiVersions: options.compatibility?.supportedApiVersions, - } - - session.channels.host.emit(moduleCompatibilityRequest, compatibilityRequest) - const protocolNegotiation = resolveNegotiatedVersion( - compatibilityRequest.protocolVersion, - this.supportedProtocolVersions, - compatibilityRequest.supportedProtocolVersions, - ) - const apiNegotiation = resolveNegotiatedVersion( - compatibilityRequest.apiVersion, - this.supportedApiVersions, - compatibilityRequest.supportedApiVersions, - ) - - const rejectionReasons = [ - ...protocolNegotiation.acceptedVersion ? [] : [`protocol: ${protocolNegotiation.reason}`], - ...apiNegotiation.acceptedVersion ? [] : [`api: ${apiNegotiation.reason}`], - ] - - if (rejectionReasons.length > 0) { - const reason = `Negotiation rejected: ${rejectionReasons.join('; ')}` - session.channels.host.emit(moduleCompatibilityResult, { - protocolVersion: compatibilityRequest.protocolVersion, - apiVersion: compatibilityRequest.apiVersion, - mode: 'rejected', - reason, - }) - throw new Error(reason) - } - - session.channels.host.emit(moduleCompatibilityResult, { - protocolVersion: protocolNegotiation.acceptedVersion!, - apiVersion: apiNegotiation.acceptedVersion!, - mode: protocolNegotiation.exact && apiNegotiation.exact ? 'exact' : 'downgraded', - }) - - // Step 4: broadcast currently known modules for dependency discovery/bootstrap. - session.channels.host.emit(registryModulesSync, { - modules: this.listSessions() - .filter(item => item.phase !== 'stopped') - .map(item => ({ - name: item.manifest.name, - index: item.index, - identity: item.identity, - })), - }) - - 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, - phase: 'announced', - }) - - // Step 6/7: preparing phase (dependency/config preparation may happen inside plugin init). - assertTransition(session, 'preparing') - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'preparing', - }) - - // Optional dependency gate before plugin-owned initialization. - if (options.requiredCapabilities?.length) { - const capabilityTimeoutMs = options.capabilityWaitTimeoutMs ?? 15000 - const unresolvedCapabilities = options.requiredCapabilities.filter(key => !this.isCapabilityReady(key)) - assertTransition(session, 'waiting-deps') - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'preparing', - reason: `Waiting for capabilities: ${options.requiredCapabilities.join(', ')}`, - details: { - // For richer observability - lifecyclePhase: 'waiting-deps', - requiredCapabilities: options.requiredCapabilities, - unresolvedCapabilities, - timeoutMs: capabilityTimeoutMs, - }, - }) - - await this.waitForCapabilities(options.requiredCapabilities, capabilityTimeoutMs) - assertTransition(session, 'prepared') - session.channels.host.emit(modulePrepared, { - identity: session.identity, - }) - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'prepared', - }) - preparedEmitted = true - } - - // Run plugin-owned init hook. Returning `false` explicitly aborts startup. - const initResult = await session.plugin.init?.({ - channels: session.channels, - apis: session.apis, - }) - - if (initResult === false) { - throw new Error(`Plugin initialization aborted by plugin: ${session.manifest.name}`) - } - - // Step 8/10: module prepared. - if (!preparedEmitted) { - assertTransition(session, 'prepared') - session.channels.host.emit(modulePrepared, { - identity: session.identity, - }) - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'prepared', - }) - } - - // Step 9/11: allow host to stop at explicit "configuration-needed". - if (options.requireConfiguration) { - assertTransition(session, 'configuration-needed') - session.channels.host.emit(moduleConfigurationNeeded, { - identity: session.identity, - reason: 'Host requested configuration before activation.', - }) - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'configuration-needed', - }) - - return session - } - - // Step 12/13: apply default config path for alpha when no manual configuration is required. - await this.applyConfiguration(session.id, { - configId: `${session.identity.id}:default`, - revision: 1, - schemaVersion: 1, - full: {}, - }) - - // Step 14/15: plugin contributes modules/capabilities in setup hook. - await session.plugin.setupModules?.({ - channels: session.channels, - apis: session.apis, - }) - - // Step 16: mark ready after setup/contribution flow completes. - assertTransition(session, 'ready') - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'ready', - }) - this.runLifecycleHooks('session-ready', session) - - return session - } - catch (error) { - // Any init failure is normalized into failed phase + status event for observability. - markFailedTransition(session) - - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'failed', - reason: errorMessageFrom(error) ?? 'Plugin host initialization failed.', - }) - - this.cleanupSession(session) - - throw error - } - } - - async start(manifest: ManifestV1, options: PluginStartOptions = {}) { - // Convenience wrapper: "start" = load + init in sequence. - // Keep this tiny so callers can still call `load`/`init` separately when needed. - const session = await this.load(manifest, { + async start(manifest: ExtensionManifestV1, options: ExtensionStartOptions = {}): Promise { + const extension = await this.loader.loadExtensionFor(manifest, { cwd: options.cwd, runtime: options.runtime, }) - return this.init(session.id, options) - } - - async applyConfiguration(sessionId: string, config: ModuleConfigEnvelope) { - // Configuration is allowed only after prepare, during configuration-needed, or while re-configuring. - const session = this.sessionService.get(sessionId) - if (!session) { - throw new Error(`Unable to configure plugin session: ${sessionId}`) - } - - if (!['prepared', 'configuration-needed', 'configured'].includes(session.phase)) { - throw new Error(`Session ${sessionId} cannot accept configuration during phase ${session.phase}.`) - } - - // Move into configured once per cycle; repeated apply is allowed while already configured. - if (session.phase !== 'configured') { - assertTransition(session, 'configured') - } - - // Emit configured payload + status so Configurator can sync active config state. - session.channels.host.emit(moduleConfigurationConfigured, { - identity: session.identity, - config, - }) - - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'configured', + const session = await this.startExtension(extension, { + manifest, + cwd: options.cwd, + runtime: options.runtime, }) return session } - requestPermissions(sessionId: string, requested: ModulePermissionDeclaration, reason?: string) { - const session = this.sessionService.get(sessionId) - if (!session) { - throw new Error(`Unable to request permissions for plugin session: ${sessionId}`) - } - - const snapshot = this.permissions.declare(this.getPermissionScopeKey(session), requested) - session.permissions = { - requested: snapshot.requested, - granted: snapshot.granted, - revision: snapshot.revision, - } - - session.channels.host.emit(modulePermissionsDeclare, { - identity: session.identity, - requested: snapshot.requested, - source: 'runtime', - }) - session.channels.host.emit(modulePermissionsCurrent, { - identity: session.identity, - requested: snapshot.requested, - granted: snapshot.granted, - revision: snapshot.revision, - }) - session.channels.host.emit(modulePermissionsRequest, { - identity: session.identity, - requested: snapshot.requested, - reason, - }) - } - - grantPermissions( - sessionId: string, - grant: ModulePermissionGrant, - ): { - requested: ModulePermissionDeclaration - granted: ModulePermissionGrant - revision: number - } { - const session = this.sessionService.get(sessionId) - if (!session) { - throw new Error(`Unable to grant permissions for plugin session: ${sessionId}`) - } - - const snapshot = this.permissions.grant(this.getPermissionScopeKey(session), grant) - session.permissions = { - requested: snapshot.requested, - granted: snapshot.granted, - revision: snapshot.revision, - } - this.persistedPermissionGrants.set(session.identity.plugin.id, snapshot.granted) - - session.channels.host.emit(modulePermissionsGranted, { - identity: session.identity, - granted: snapshot.granted, - revision: snapshot.revision, - }) - session.channels.host.emit(modulePermissionsCurrent, { - identity: session.identity, - requested: snapshot.requested, - granted: snapshot.granted, - revision: snapshot.revision, - }) - - return snapshot - } - setResourceResolver(key: string, resolver: () => Promise | T) { this.resources.setResolver(key, resolver) } @@ -1726,62 +829,30 @@ export class PluginHost { 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.sessionService.get(sessionId) - if (!session) { - throw new Error(`Unable to update plugin session: ${sessionId}`) - } - - if (!['prepared', 'configured', 'ready', 'announced'].includes(session.phase)) { - throw new Error(`Session ${sessionId} cannot move to configuration-needed from ${session.phase}.`) - } - - // Assert guarded transition to avoid illegal phase jumps. - assertTransition(session, 'configuration-needed') - session.channels.host.emit(moduleConfigurationNeeded, { - identity: session.identity, - reason, - }) - session.channels.host.emit(moduleStatus, { - identity: session.identity, - phase: 'configuration-needed', - reason, - }) - - return session - } - - stop(sessionId: string) { - // Stop removes session from active registry. Lifecycle first transitions to `stopped`. - const session = this.sessionService.get(sessionId) - if (!session) { + async stop(sessionId: string): Promise { + const extensionSession = this.extensionSessionService.get(sessionId) + if (!extensionSession) { return undefined } - const lifecycleHookError = this.cleanupSession(session) - if (lifecycleHookError) { - throw lifecycleHookError - } - - return session + await this.cleanupExtensionSession(extensionSession) + return extensionSession } - async reload(sessionId: string, options: PluginStartOptions = {}) { + async reload(sessionId: string, options: ExtensionStartOptions = {}): Promise { // Reload preserves manifest/runtime intent, then performs stop + fresh start. // This intentionally creates a new session identity for deterministic re-bootstrap. - const previous = this.sessionService.get(sessionId) - if (!previous) { - throw new Error(`Unable to reload missing plugin session: ${sessionId}`) + const previousExtension = this.extensionSessionService.get(sessionId) + if (!previousExtension) { + throw new Error(`Unable to reload missing extension session: ${sessionId}`) } - const manifest = previous.manifest - this.stop(sessionId) + const manifest = previousExtension.manifest + await this.cleanupExtensionSession(previousExtension) return this.start(manifest, { ...options, - cwd: options.cwd ?? previous.cwd, - runtime: options.runtime ?? previous.runtime, + cwd: options.cwd ?? previousExtension.cwd, + runtime: options.runtime ?? previousExtension.runtime, }) } } diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts index 0ce1bc6a2..e9db7e621 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts @@ -10,10 +10,10 @@ export * from '../../transports' export * from './loaders' /** - * Creates the Eventa context used by node-side plugin host sessions. + * Creates the Eventa context used by node-side extension host sessions. * * Use when: - * - Bootstrapping a node runtime plugin session + * - Bootstrapping a node runtime extension session * * Expects: * - `transport` describes a transport supported by the node runtime diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts index e06c06714..5a5884c6e 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts @@ -1,55 +1,45 @@ -import type { definePlugin } from '../../../../plugin' -import type { Plugin } from '../../../../plugin/shared' -import type { ManifestV1, PluginLoadOptions } from '../../../shared/types' +import type { Extension } from '../../../../extension' +import type { ExtensionLoadOptions, ExtensionManifestV1 } from '../../../shared/types' import { isAbsolute, join } from 'node:path' import { cwd } from 'node:process' -function isPluginDefinition(value: unknown): value is ReturnType { +function isExtensionDefinition(value: unknown): value is Extension { return typeof value === 'object' && value !== null + && 'id' in value + && typeof (value as { id?: unknown }).id === 'string' && 'setup' in value && typeof (value as { setup?: unknown }).setup === 'function' } -async function coercePluginFromModule(moduleValue: unknown): Promise { - if (isPluginDefinition(moduleValue)) { - return await moduleValue.setup() +function coerceExtensionFromModule(moduleValue: unknown): Extension { + if (isExtensionDefinition(moduleValue)) { + return moduleValue } if (typeof moduleValue === 'object' && moduleValue !== null) { - if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) { - return await (moduleValue as { default: ReturnType }).default.setup() - } - - if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') { - const defaultPlugin = (moduleValue as { default: Plugin }).default - if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') { - return defaultPlugin - } - } - - const plugin = moduleValue as Plugin - if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') { - return plugin + const defaultExport = (moduleValue as { default?: unknown }).default + if (isExtensionDefinition(defaultExport)) { + return defaultExport } } - throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.') + throw new Error('Failed to resolve extension module. The entrypoint must export defineExtension(...).') } /** - * Loads plugin entrypoints from the local filesystem for the current runtime. + * Loads extension entrypoints from the local filesystem for the current runtime. * * Use when: * - The host needs to resolve a manifest entrypoint path - * - The host needs to import either a lazy `definePlugin(...)` export or a concrete plugin module + * - The host needs to import a `defineExtension(...)` export * * Expects: * - Entry points are valid importable module paths for the active runtime * * Returns: - * - Filesystem-backed helpers for resolving and loading plugin entrypoints + * - Filesystem-backed helpers for resolving and loading extension entrypoints */ export class FileSystemLoader { /** @@ -58,9 +48,9 @@ export class FileSystemLoader { * Resolution order: * 1) `entrypoints.` * 2) `entrypoints.default` - * 3) `entrypoints.electron` (legacy fallback for current local plugin manifests) + * 3) `entrypoints.electron` (legacy fallback for current local extension manifests) */ - resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) { + resolveEntrypointFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) { const runtime = options?.runtime ?? 'electron' const root = options?.cwd ?? cwd() const entrypoint @@ -70,36 +60,18 @@ export class FileSystemLoader { if (!entrypoint) { throw new Error('' - + `Plugin entrypoint is required for runtime \`${runtime}\`. ` + + `Extension entrypoint is required for runtime \`${runtime}\`. ` + 'Define one of `entrypoints.`, `entrypoints.default`, ' - + 'or `entrypoints.electron` in the plugin manifest.', + + 'or `entrypoints.electron` in the extension manifest.', ) } return isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint) } - async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { + async loadExtensionFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) { 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) + const extensionModule = await import(entrypoint) + return coerceExtensionFromModule(extensionModule) } } diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts index 2a2cbf1c0..bfb15c8d3 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts @@ -1,7 +1,7 @@ import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol' /** - * Tracks capability lifecycle state and waits for readiness across plugin sessions. + * Tracks capability lifecycle state and waits for readiness across extension sessions. * * Use when: * - The host needs to announce, ready, degrade, or withdraw named capabilities diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.test.ts similarity index 58% rename from packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts rename to packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.test.ts index 93088a372..d7a307ff8 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.test.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { PluginSessionService } from './sessions' +import { ExtensionSessionService } from './extension-sessions' vi.mock('nanoid/non-secure', () => ({ nanoid: vi @@ -14,9 +14,9 @@ interface TestSession { state: 'active' | 'closed' } -describe('pluginSessionService', () => { +describe('extensionSessionService', () => { it('registers, lists, gets, and removes sessions by id', () => { - const service = new PluginSessionService() + const service = new ExtensionSessionService() const firstSession: TestSession = { id: 'session-1', state: 'active' } const secondSession: TestSession = { id: 'session-2', state: 'closed' } @@ -35,31 +35,17 @@ describe('pluginSessionService', () => { expect(service.remove('session-1')).toBeUndefined() }) - it('generates random session ids and incrementing module identities with sanitized plugin names', () => { - const service = new PluginSessionService() + it('generates random session ids with incrementing indexes', () => { + const service = new ExtensionSessionService() - expect(service.nextSessionIdentity(' demo-plugin ')).toEqual({ + expect(service.nextSessionIdentity()).toEqual({ index: 0, - sessionId: 'plugin-session-session-a', - moduleIdentity: { - id: 'demo-plugin-0', - kind: 'plugin', - plugin: { - id: 'demo-plugin', - }, - }, + sessionId: 'extension-session-session-a', }) - expect(service.nextSessionIdentity(' ')).toEqual({ + expect(service.nextSessionIdentity()).toEqual({ index: 1, - sessionId: 'plugin-session-session-b', - moduleIdentity: { - id: 'plugin-1', - kind: 'plugin', - plugin: { - id: 'plugin', - }, - }, + sessionId: 'extension-session-session-b', }) }) }) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.ts similarity index 53% rename from packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts rename to packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.ts index 447733b58..323a94dd1 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/extension-sessions.ts @@ -1,25 +1,11 @@ -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, - }, - } -} - /** - * Stores plugin sessions and generates deterministic session identities. + * Stores extension host sessions and generates deterministic session identities. * * Use when: - * - The host needs to track loaded plugin sessions by id - * - New plugin sessions need a generated session id and module identity + * - The host needs to track loaded extension sessions by id + * - New extension sessions need a generated session id and module identity * * Expects: * - `TSession` has a stable `id` field used as the registry key @@ -27,7 +13,7 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity { * Returns: * - An in-memory session registry with identity generation helpers */ -export class PluginSessionService { +export class ExtensionSessionService { private readonly sessions = new Map() private sessionCounter = 0 @@ -54,14 +40,13 @@ export class PluginSessionService { return session } - nextSessionIdentity(name: string) { + nextSessionIdentity() { const index = this.sessionCounter this.sessionCounter += 1 return { index, - sessionId: `plugin-session-${nanoid()}`, - moduleIdentity: createModuleIdentity(name, index), + sessionId: `extension-session-${nanoid()}`, } } } diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts index a36b45669..b87030e6e 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts @@ -1,7 +1,6 @@ -export * from './bindings' export * from './dependencies' +export * from './extension-sessions' +export * from './kit-api-bindings' export * from './kits' export * from './permissions' export * from './resources' -export * from './sessions' -export * from './tools' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.test.ts similarity index 81% rename from packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts rename to packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.test.ts index 8f9293c63..1c97bacae 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.test.ts @@ -1,10 +1,27 @@ import { describe, expect, it } from 'vitest' -import { BindingsRegistryService } from './bindings' +import { KitApiBindingRegistryService } from './kit-api-bindings' + +describe('kitApiBindingRegistryService', () => { + it('stores kit API bindings by owning extension session and module', () => { + const service = new KitApiBindingRegistryService() + + const binding = service.bind({ + moduleId: 'chess-gamelet', + ownerSessionId: 'session-1', + ownerPluginId: 'airi-extension-chess', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { title: 'Chess' }, + runtime: 'electron', + }) + + expect(binding.state).toBe('announced') + expect(service.listByModule('session-1', 'chess-gamelet')).toEqual([binding]) + }) -describe('bindingsRegistryService', () => { it('rejects ownership violations when updating a module from another session', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() service.bind({ moduleId: 'm1', @@ -20,7 +37,7 @@ describe('bindingsRegistryService', () => { }) it('tracks lifecycle transitions with revision bumps and preserved ownership', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() const announced = service.bind({ moduleId: 'm2', @@ -45,7 +62,7 @@ describe('bindingsRegistryService', () => { }) it('rejects invalid lifecycle transitions after withdrawal', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() service.bind({ moduleId: 'm3', @@ -63,7 +80,7 @@ describe('bindingsRegistryService', () => { }) it('rejects duplicate module ids from a different owner session', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() service.bind({ moduleId: 'm4', @@ -89,7 +106,7 @@ describe('bindingsRegistryService', () => { }) it('returns the existing record for an idempotent duplicate bind from the same owner', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() const original = service.bind({ moduleId: 'm5', @@ -118,7 +135,7 @@ describe('bindingsRegistryService', () => { }) it('rejects module reuse with the same session but a different owner plugin', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() service.bind({ moduleId: 'm6', @@ -144,7 +161,7 @@ describe('bindingsRegistryService', () => { }) it('removes a withdrawn binding with unbind for teardown flows', () => { - const service = new BindingsRegistryService() + const service = new KitApiBindingRegistryService() service.bind({ moduleId: 'm7', diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.ts similarity index 90% rename from packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts rename to packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.ts index adf0980b4..b8f36deb0 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kit-api-bindings.ts @@ -5,17 +5,17 @@ import type { HostDataRecord, PluginRuntime } from '../../../shared/types' * Declares the host-owned data needed to create one binding record. * * Use when: - * - A plugin session contributes a concrete runtime instance through a kit + * - A extension session contributes a concrete runtime instance through a kit * - Higher-level kit helpers need to persist their low-level binding into the host registry * * Expects: - * - `moduleId` is stable within the owning plugin session + * - `moduleId` is stable within the owning extension session * - `kitId` points at a host-registered kit that defines the binding family * - `kitModuleType` is a kit-defined subtype key, not a host-wide enum * - `config` is transport-safe and already normalized by the caller * * Returns: - * - A serializable payload that {@link BindingsRegistryService.bind} stores as canonical binding state + * - A serializable payload that {@link KitApiBindingRegistryService.bind} stores as canonical binding state */ export interface BindingInput { moduleId: string @@ -39,7 +39,7 @@ export interface BindingInput { * - `config` only contains fields that should be shallow-merged into the current config * * Returns: - * - A partial mutation applied by {@link BindingsRegistryService.update} or {@link BindingsRegistryService.transition} + * - A partial mutation applied by {@link KitApiBindingRegistryService.update} or {@link KitApiBindingRegistryService.transition} */ export interface BindingUpdatePatch { state?: BindingState @@ -47,10 +47,10 @@ export interface BindingUpdatePatch { } /** - * Identifies the plugin session that owns a binding record. + * Identifies the extension session that owns a binding record. * * Use when: - * - Enforcing that only the original plugin session mutates or removes a binding + * - Enforcing that only the original extension session mutates or removes a binding * - Comparing current callers against stored binding ownership * * Expects: @@ -112,12 +112,12 @@ function createInvalidTransitionError(moduleId: string, from: BindingState, to: * Returns: * - Stable {@link BindingRecord} snapshots representing bound runtime contributions * - * A binding is the concrete link between a plugin-owned runtime instance and a host-registered kit. + * A binding is the concrete link between a extension-owned runtime instance and a host-registered kit. * The host keeps kits generic: a kit only describes capabilities, supported runtimes, and allowed * operations. That is not enough to render UI, route lifecycle, or enforce ownership for a specific * plugin contribution. The missing piece is a binding record saying: * - * - plugin session `X` owns runtime instance `moduleId` + * - extension session `X` owns runtime instance `moduleId` * - that instance is attached to kit `kitId` * - within that kit it behaves as subtype `kitModuleType` * - here is its current generic config payload and lifecycle state @@ -170,11 +170,11 @@ function createInvalidTransitionError(moduleId: string, from: BindingState, to: * - `widget-main` under `kit.widget` * - after: one registry, multiple kit families, each record still resolved by the same ownership rules */ -export class BindingsRegistryService { +export class KitApiBindingRegistryService { private readonly bindings = new Map>() /** - * Creates or reuses one binding record for a plugin-owned runtime instance. + * Creates or reuses one binding record for a extension-owned runtime instance. * * Use when: * - A plugin or kit helper needs to declare that a concrete instance now exists @@ -276,7 +276,7 @@ export class BindingsRegistryService } /** - * Lists bindings owned by one plugin session. + * Lists bindings owned by one extension session. * * Use when: * - Stopping or reloading a session @@ -292,6 +292,26 @@ export class BindingsRegistryService return this.list().filter(binding => binding.ownerSessionId === ownerSessionId) } + /** + * Lists bindings owned by one module in an extension/extension session. + * + * Use when: + * - Module-scoped cleanup or devtools need the bindings for one registered module + * + * Expects: + * - `ownerSessionId` is the extension/extension session id + * - `moduleId` is the concrete kit API binding id + * + * Returns: + * - All matching binding records + */ + listByModule(ownerSessionId: string, moduleId: string) { + return this.list().filter(binding => + binding.ownerSessionId === ownerSessionId + && binding.moduleId === moduleId, + ) + } + /** * Lists bindings attached to one kit family. * @@ -434,7 +454,7 @@ export class BindingsRegistryService * Physically removes a withdrawn-or-obsolete binding record from the registry. * * Use when: - * - Stopping or reloading a plugin session after lifecycle cleanup + * - Stopping or reloading a extension session after lifecycle cleanup * - The host wants to forget a binding entirely, not merely mark it withdrawn * * Expects: diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts index 239f753df..59073f41c 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.test.ts @@ -208,4 +208,23 @@ describe('permissionService', () => { expect(service.isAllowed('plugin-d', 'apis', 'emit', 'plugin.api.users')).toBe(false) expect(service.isAllowed('plugin-d', 'apis', 'invoke', 'plugin.api.billing')).toBe(false) }) + + it('caps module grants by the extension permission ceiling', () => { + const service = new PermissionService() + const extension = service.initialize('extension-session', { + apis: [{ key: 'kit.tools.register', actions: ['invoke'] }], + }) + const module = service.initialize('module-session', { + apis: [ + { key: 'kit.tools.register', actions: ['invoke'] }, + { key: 'kit.gamelet.open', actions: ['invoke'] }, + ], + }) + + const effective = service.intersectGrant(extension.granted, module.requested) + + expect(effective.apis).toEqual([ + { key: 'kit.tools.register', actions: ['invoke'] }, + ]) + }) }) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts index 01d5dfb28..743872ddf 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts @@ -253,7 +253,7 @@ function mergePermissionDeclarations( } /** - * Tracks requested and granted permissions for plugin sessions. + * Tracks requested and granted permissions for extension sessions. * * Use when: * - The host needs to initialize permission state for a session @@ -269,6 +269,29 @@ function mergePermissionDeclarations( export class PermissionService { private readonly store = new Map() + /** + * Computes the effective permission boundary for one module. + * + * Use when: + * - Extension permissions define the install/session-level ceiling + * - Module permissions describe actual runtime usage + * + * Expects: + * - `extensionGrant` is the already granted extension-level ceiling + * - `moduleRequest` is the module-level requested usage + * + * Returns: + * - The intersection that stays within both extension and module boundaries + */ + intersectGrant( + extensionGrant: ModulePermissionGrant, + moduleRequest: ModulePermissionDeclaration, + ): ModulePermissionGrant { + // Extension grants are the package/session ceiling; module requests are + // actual runtime usage. Effective access must stay inside both boundaries. + return intersectPermissions(normalizeDeclaration(moduleRequest), normalizeDeclaration(extensionGrant)) + } + initialize( pluginId: string, requestedDeclaration: ModulePermissionDeclaration, @@ -342,4 +365,12 @@ export class PermissionService { && hasAction(scope.actions, action), ) } + + grantAllows(grant: ModulePermissionGrant, area: ModulePermissionArea, action: string, key: string) { + const scopes = grant[area] ?? [] + return scopes.some(scope => + matchKey(scope.key, key) + && hasAction(scope.actions, action), + ) + } } diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts deleted file mode 100644 index f9609ae68..000000000 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - PluginToolDefinitionRecord, - PluginToolsetPromptDefinitionRecord, - RegisteredPluginToolDescriptor, - SerializedToolsetPromptDefinition, - SerializedXsaiToolDefinition, - SerializedXsaiToolsetDefinition, -} from '../../../shared' - -/** - * Stores one plugin tool registration inside the in-memory host runtime. - * - * Use when: - * - Tracking tool ownership and availability per plugin session - * - * Expects: - * - `ownerPluginId` and `tool.id` together are unique - * - * Returns: - * - A host-managed record used for listing and invocation - */ -export interface ToolRegistryRecord { - ownerSessionId: string - ownerPluginId: string - tool: PluginToolDefinitionRecord - availability?: () => Promise | boolean - execute: (input: unknown) => Promise | unknown -} - -/** - * Stores one plugin toolset prompt registration inside the in-memory host runtime. - * - * Use when: - * - Tracking prompt ownership and lifecycle for a plugin-owned toolset - * - * Expects: - * - `ownerPluginId` and `toolset.id` together are unique - * - * Returns: - * - A host-managed record used for prompt serialization - */ -export interface ToolsetPromptRegistryRecord { - ownerSessionId: string - ownerPluginId: string - toolset: PluginToolsetPromptDefinitionRecord - availability?: () => Promise | boolean -} - -/** - * In-memory registry for plugin-contributed tools. - * - * Use when: - * - The host needs to list plugin tools for UI and xsai consumers - * - The host needs to dispatch a tool invocation back to its owning plugin - * - * Expects: - * - Callers filter by ownership through `ownerPluginId` - * - * Returns: - * - Serialisable metadata views and invoke routing - */ -export class ToolRegistryService { - private readonly tools = new Map() - private readonly toolsetPrompts = new Map() - - register(record: ToolRegistryRecord) { - const key = `${record.ownerPluginId}:${record.tool.id}` - this.tools.set(key, record) - return record - } - - registerToolsetPrompt(record: ToolsetPromptRegistryRecord) { - const key = `${record.ownerPluginId}:${record.toolset.id}` - this.toolsetPrompts.set(key, record) - return record - } - - async listAvailableDescriptors() { - const items: RegisteredPluginToolDescriptor[] = [] - - for (const record of this.tools.values()) { - if (await record.availability?.() === false) { - continue - } - - items.push({ - id: record.tool.id, - title: record.tool.title, - description: record.tool.description, - activation: { - keywords: [...record.tool.activation.keywords], - patterns: [...record.tool.activation.patterns], - }, - }) - } - - return items - } - - async listToolsetPrompts() { - const prompts: SerializedToolsetPromptDefinition[] = [] - - for (const record of this.toolsetPrompts.values()) { - if (await record.availability?.() === false) { - continue - } - - prompts.push({ - ownerPluginId: record.ownerPluginId, - id: record.toolset.id, - prompt: structuredClone(record.toolset.prompt), - }) - } - - return prompts - } - - async listSerializedXsaiTools(): Promise { - const items: SerializedXsaiToolDefinition[] = [] - - for (const record of this.tools.values()) { - if (await record.availability?.() === false) { - continue - } - - items.push({ - ownerPluginId: record.ownerPluginId, - name: record.tool.id, - description: record.tool.description, - parameters: structuredClone(record.tool.parameters), - }) - } - - return { - prompts: await this.listToolsetPrompts(), - tools: items, - } - } - - async invoke(ownerPluginId: string, toolId: string, input: unknown) { - const key = `${ownerPluginId}:${toolId}` - const record = this.tools.get(key) - if (!record) { - throw new Error(`Plugin tool not found: ${key}`) - } - - return await record.execute(input) - } -} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts index eeca3b62f..0751fb4e0 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts @@ -9,10 +9,10 @@ export * from '../../shared' export * from '../../transports' /** - * Creates the Eventa context used by web-side plugin host sessions. + * Creates the Eventa context used by web-side extension host sessions. * * Use when: - * - Bootstrapping a web runtime plugin session + * - Bootstrapping a web runtime extension session * * Expects: * - `transport` describes a transport supported by the web runtime diff --git a/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts b/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts index 9de0f4469..475bb4d3f 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts @@ -7,7 +7,7 @@ describe('bindingRecordSchema', () => { it('accepts generic host-level module record without business coupling', () => { const parsed = parse(bindingRecordSchema, { moduleId: 'board-main', - ownerSessionId: 'plugin-session-1', + ownerSessionId: 'extension-session-1', ownerPluginId: 'demo-plugin', kitId: 'kit.widget', kitModuleType: 'panel', @@ -26,7 +26,7 @@ describe('bindingRecordSchema', () => { expect(() => parse(bindingRecordSchema, { moduleId: 'board-main', - ownerSessionId: 'plugin-session-1', + ownerSessionId: 'extension-session-1', ownerPluginId: 'demo-plugin', kitId: 'kit.widget', kitModuleType: 'panel', @@ -43,7 +43,7 @@ describe('bindingRecordSchema', () => { expect(() => parse(bindingRecordSchema, { moduleId: 'board-main', - ownerSessionId: 'plugin-session-1', + ownerSessionId: 'extension-session-1', ownerPluginId: 'demo-plugin', kitId: 'kit.widget', kitModuleType: 'panel', @@ -64,7 +64,7 @@ describe('bindingRecordSchema', () => { expect(() => parse(bindingRecordSchema, { moduleId: 'board-main', - ownerSessionId: 'plugin-session-1', + ownerSessionId: 'extension-session-1', ownerPluginId: 'demo-plugin', kitId: 'kit.widget', kitModuleType: 'panel', diff --git a/packages/plugin-sdk/src/plugin-host/shared/index.ts b/packages/plugin-sdk/src/plugin-host/shared/index.ts index 388d81e38..9e4ccb431 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/index.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/index.ts @@ -1,4 +1,3 @@ export * from './bindings' export * from './kits' -export * from './tools' export * from './types' diff --git a/packages/plugin-sdk/src/plugin-host/shared/tools.ts b/packages/plugin-sdk/src/plugin-host/shared/tools.ts deleted file mode 100644 index d4c3b62e6..000000000 --- a/packages/plugin-sdk/src/plugin-host/shared/tools.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { HostDataRecord } from './types' - -/** - * Describes the user-facing metadata for a plugin-contributed tool. - * - * Use when: - * - Listing plugin tools in renderer or devtools surfaces - * - Exposing activation hints without the execution handler - * - * Expects: - * - `id` is stable and unique within the owning plugin - * - * Returns: - * - A serializable descriptor suitable for host and renderer registries - */ -export interface RegisteredPluginToolDescriptor { - id: string - title: string - description: string - activation: { - keywords: string[] - patterns: string[] - } -} - -/** - * Describes the JSON-schema side of an xsai-compatible tool. - * - * Use when: - * - Serializing plugin tools across Electron boundaries - * - Reconstructing proxy `rawTool(...)` instances in the renderer - * - * Expects: - * - `parameters` is a provider-safe JSON Schema object - * - * Returns: - * - A serializable tool contract without executable callbacks - */ -export interface SerializedXsaiToolDefinition { - ownerPluginId: string - name: string - description: string - parameters: HostDataRecord -} - -/** - * Describes model-facing guidance shared by every tool in one plugin toolset. - * - * Use when: - * - A toolset needs shared usage policy without duplicating prompt text on each tool - * - * Expects: - * - `content` is ready to append into a runtime system prompt - * - * Returns: - * - A serializable manifest that the host can pass to renderer prompt stores - */ -export interface ToolsetPromptManifest { - id: string - title?: string - content: string -} - -/** - * Captures one registered toolset prompt with plugin ownership metadata. - * - * Use when: - * - Serializing plugin-contributed toolset guidance across host boundaries - * - * Expects: - * - `id` is stable within the owning plugin session - * - * Returns: - * - A prompt contribution suitable for renderer LLM prompt injection - */ -export interface SerializedToolsetPromptDefinition { - ownerPluginId: string - id: string - prompt: ToolsetPromptManifest -} - -/** - * Bundles plugin xsai tools with their shared toolset prompt contributions. - * - * Use when: - * - The renderer refreshes plugin-backed tools and model prompt guidance together - * - * Expects: - * - Tools and prompts have already been filtered for active sessions - * - * Returns: - * - A serializable snapshot for renderer tool and prompt stores - */ -export interface SerializedXsaiToolsetDefinition { - tools: SerializedXsaiToolDefinition[] - prompts: SerializedToolsetPromptDefinition[] -} - -/** - * Captures the single source-of-truth definition submitted by a plugin. - * - * Use when: - * - Registering tools from plugin runtimes into the host - * - * Expects: - * - `parameters` already contains a serialized input schema - * - * Returns: - * - A host-owned record that can be derived into UI metadata and xsai schemas - */ -export interface PluginToolDefinitionRecord { - id: string - title: string - description: string - activation: { - keywords: string[] - patterns: string[] - } - parameters: HostDataRecord -} - -/** - * Captures a plugin-owned prompt shared by a toolset. - * - * Use when: - * - A plugin registers model-facing guidance for a group of related tools - * - * Expects: - * - `prompt` content is validated by the authoring helper or caller - * - * Returns: - * - A host-owned record that can be filtered by session lifecycle - */ -export interface PluginToolsetPromptDefinitionRecord { - id: string - prompt: ToolsetPromptManifest -} diff --git a/packages/plugin-sdk/src/plugin-host/shared/types.ts b/packages/plugin-sdk/src/plugin-host/shared/types.ts index 056989c43..9b4b3d519 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/types.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/types.ts @@ -1,14 +1,9 @@ import type { - ProtocolEvents, - ModuleConfigEnvelope as ProtocolModuleConfigEnvelope, - ModuleIdentity as ProtocolModuleIdentity, + ExtensionIdentity as ProtocolExtensionIdentity, ModulePermissionDeclaration as ProtocolModulePermissionDeclaration, ModulePermissionGrant as ProtocolModulePermissionGrant, - ModulePhase as ProtocolModulePhase, - PluginIdentity as ProtocolPluginIdentity, } from '@proj-airi/plugin-protocol/types' -import type { PluginTransport } from '../transports' import type { KitDescriptor } from './kits' import { isPlainObject } from 'es-toolkit' @@ -32,7 +27,7 @@ import { } from 'valibot' /** - * Lists the supported plugin runtimes recognized by the host. + * Lists the supported extension runtimes recognized by the host. * * Use when: * - Validating manifest entrypoints or host runtime configuration @@ -46,7 +41,7 @@ import { */ export const pluginRuntimeValues = ['electron', 'node', 'web'] as const /** - * Describes one supported plugin runtime. + * Describes one supported extension runtime. * * Use when: * - Typing host runtime configuration and manifest runtime selection @@ -68,7 +63,7 @@ export type PluginRuntime = typeof pluginRuntimeValues[number] * - Inputs are runtime strings such as `electron`, `node`, or `web` * * Returns: - * - A Valibot schema for one plugin runtime literal + * - A Valibot schema for one extension runtime literal */ export const pluginRuntimeSchema = picklist(pluginRuntimeValues) @@ -188,116 +183,24 @@ export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataVal export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0)) /** - * Re-exports the protocol module phase literals used by the host. + * Re-exports the protocol extension identity model used by the host. * * Use when: - * - Typing module lifecycle phases shared with `@proj-airi/plugin-protocol` + * - Typing package/session-level extension authorization callbacks * * Expects: - * - Values follow the protocol package lifecycle model + * - Values originate from extension manifests and host session identity generation * * Returns: - * - The protocol-defined module phase union + * - The protocol-defined extension identity type */ -export type ModulePhase = ProtocolModulePhase - -/** - * Describes all phases a plugin session can occupy inside `PluginHost`. - * - * Use when: - * - Typing `PluginHostSession.phase` - * - Checking host lifecycle transitions - * - * Expects: - * - Protocol phases are extended with host-only bootstrap and shutdown phases - * - * Returns: - * - The full plugin-session lifecycle union - */ -export type PluginSessionPhase - = | 'loading' - | 'loaded' - | 'authenticating' - | 'authenticated' - | 'waiting-deps' - | ModulePhase - | 'stopped' - -/** - * Re-exports the protocol plugin identity model used by the host. - * - * Use when: - * - Typing per-plugin identity values stored on sessions and events - * - * Expects: - * - Values originate from the protocol identity generator or host session service - * - * Returns: - * - The protocol-defined plugin identity type - */ -export type PluginIdentity = ProtocolPluginIdentity - -/** - * Re-exports the protocol module identity model used by the host. - * - * Use when: - * - Typing plugin session identities and protocol event payloads - * - * Expects: - * - Values originate from the protocol identity generator or host session service - * - * Returns: - * - The protocol-defined module identity type - */ -export type ModuleIdentity = ProtocolModuleIdentity - -/** - * Re-exports the protocol configuration envelope used for plugin configuration state. - * - * Use when: - * - Typing configuration payloads stored or emitted by the host - * - * Expects: - * - `C` describes the full configuration object carried in the envelope - * - * Returns: - * - The protocol-defined configuration envelope type - */ -export type ModuleConfigEnvelope> = ProtocolModuleConfigEnvelope - -/** - * Re-exports the protocol compatibility request payload type. - * - * Use when: - * - Typing compatibility negotiation messages in the host - * - * Expects: - * - Values conform to the protocol event payload - * - * Returns: - * - The protocol-defined compatibility request type - */ -export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request'] - -/** - * Re-exports the protocol compatibility result payload type. - * - * Use when: - * - Typing compatibility negotiation responses in the host - * - * Expects: - * - Values conform to the protocol event payload - * - * Returns: - * - The protocol-defined compatibility result type - */ -export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result'] +export type ExtensionIdentity = ProtocolExtensionIdentity /** * Re-exports the protocol permission declaration model used by manifests and runtime permission flow. * * Use when: - * - Typing requested permissions in plugin manifests and host sessions + * - Typing requested permissions in extension manifests and host sessions * * Expects: * - Values conform to the protocol permission declaration model @@ -322,28 +225,21 @@ export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration export type ModulePermissionGrant = ProtocolModulePermissionGrant /** - * Describes a version-1 plugin manifest consumed by `PluginHost`. + * Describes a version-1 extension manifest consumed by `ExtensionHost`. * - * Use when: - * - Loading a plugin from disk or another runtime - * - Typing manifest values in tests and host options - * - * Expects: - * - `kind` and `apiVersion` match the current manifest format - * - * Returns: - * - The structured plugin manifest contract understood by the host + * Extension manifests are the install/session-level package description. Module + * registration happens later during `defineExtension({ setup })`. */ -export interface ManifestV1 { +export interface ExtensionManifestV1 { /** Manifest schema version expected by the current host implementation. */ apiVersion: 'v1' - /** Manifest kind discriminator used to identify AIRI plugin manifests. */ - kind: 'manifest.plugin.airi.moeru.ai' - /** Stable plugin name used for identity generation and display. */ - name: string - /** Requested permissions that the host will evaluate and grant. */ + /** Manifest kind discriminator used to identify AIRI extension manifests. */ + kind: 'manifest.extension.airi.moeru.ai' + /** Stable extension id used for identity generation and display. */ + id: string + /** Package/session permission ceiling that module permissions are capped by. */ permissions: ModulePermissionDeclaration - /** Runtime-specific module entrypoints that the host can resolve and import. */ + /** Runtime-specific extension entrypoints that the host can resolve and import. */ entrypoints: { /** Fallback entrypoint used when no runtime-specific path is provided. */ default?: string @@ -365,72 +261,77 @@ const localizableSchema = union([ }), ]) -/** - * Validates a version-1 plugin manifest. - * - * Use when: - * - Parsing plugin manifests before loading them into the host - * - * Expects: - * - Inputs follow the `ManifestV1` shape including permission declarations and entrypoints - * - * Returns: - * - A Valibot schema for the AIRI plugin manifest format - */ -export const manifestV1Schema = object({ - apiVersion: literal('v1'), - kind: literal('manifest.plugin.airi.moeru.ai'), - name: string(), - permissions: 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()), - }), +const permissionDeclarationSchema = 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()), + }))), +}) + +const manifestEntrypointsSchema = object({ + default: optional(string()), + electron: optional(string()), + node: optional(string()), + web: optional(string()), }) /** - * Configures how the host resolves and loads a plugin entrypoint. + * Validates a version-1 extension manifest. * * Use when: - * - Calling `PluginHost.load(...)` or loader helpers directly + * - Parsing `extension.airi.json` before loading an extension into the host + * + * Expects: + * - Inputs use `id`, not legacy plugin `name` + * - `permissions` describes the extension-level install/session ceiling + * + * Returns: + * - A Valibot schema for the AIRI extension manifest format + */ +export const extensionManifestV1Schema = object({ + apiVersion: literal('v1'), + kind: literal('manifest.extension.airi.moeru.ai'), + id: string(), + permissions: permissionDeclarationSchema, + entrypoints: manifestEntrypointsSchema, +}) + +/** + * Configures how the host resolves and loads an extension entrypoint. + * + * Use when: + * - Calling loader helpers directly * * Expects: * - Omitted fields fall back to host defaults @@ -438,7 +339,7 @@ export const manifestV1Schema = object({ * Returns: * - Runtime and working-directory overrides for one load operation */ -export interface PluginLoadOptions { +export interface ExtensionLoadOptions { /** Working directory used to resolve relative manifest entrypoints. */ cwd?: string /** Runtime used when selecting a manifest entrypoint. */ @@ -446,58 +347,29 @@ export interface PluginLoadOptions { } /** - * Configures one `PluginHost` instance. + * Configures one `ExtensionHost` instance. * * Use when: - * - Constructing a host with specific runtime, transport, or permission behavior + * - Constructing a host with specific runtime, permission, or contribution behavior * * Expects: * - Omitted fields fall back to the host defaults documented below * * Returns: - * - The host bootstrap options consumed by {@link import('../core').PluginHost} + * - The host bootstrap options consumed by {@link import('../core').ExtensionHost} */ -export interface PluginHostOptions { +export interface ExtensionHostOptions { /** Runtime used when callers do not override it per load/start call. @default 'electron' */ runtime?: PluginRuntime - /** Transport used when callers do not override it per load/start call. @default { kind: 'in-memory' } */ - transport?: PluginTransport - /** Protocol version advertised during compatibility negotiation. @default 'v1' */ - protocolVersion?: string - /** Plugin SDK API version advertised during compatibility negotiation. @default 'v1' */ - apiVersion?: string - /** Additional protocol versions the host is willing to negotiate. @default [] */ - supportedProtocolVersions?: string[] - /** Additional API versions the host is willing to negotiate. @default [] */ - supportedApiVersions?: string[] - /** Callback that decides the granted permission set for one plugin session. */ + /** Callback that decides the granted permission set for one extension session. */ permissionResolver?: (payload: { - identity: ModuleIdentity - manifest: ManifestV1 + identity: ExtensionIdentity + manifest: ExtensionManifestV1 requested: ModulePermissionDeclaration persisted?: ModulePermissionGrant }) => ModulePermissionGrant | Promise - /** Installable host features that can extend session APIs and register host behavior. @default [] */ - contributions?: PluginHostContribution[] -} - -/** - * Describes the stable session metadata exposed to host-installed contributions. - * - * Use when: - * - Building contribution-owned session APIs - * - Hooking plugin-session lifecycle work outside the core `PluginHost` - * - * Expects: - * - Values come from the currently executing plugin session - * - * Returns: - * - A minimal session context safe to pass outside `PluginHost` - */ -export interface PluginHostSessionContext { - sessionId: string - ownerPluginId: string - runtime: PluginRuntime + /** Installable host features that can register kits, resources, and capabilities. @default [] */ + contributions?: ExtensionHostContribution[] } /** @@ -510,9 +382,9 @@ export interface PluginHostSessionContext { * - The permission key/action pair matches the manifest permission contract * * Returns: - * - The permission request consumed by `PluginHost.assertPermission(...)` + * - The permission request consumed by `ExtensionHost.assertPermission(...)` */ -export interface PluginHostPermissionRequest { +export interface ExtensionHostPermissionRequest { area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines' action: string key: string @@ -523,19 +395,16 @@ export interface PluginHostPermissionRequest { * Provides the host-owned registration surface that contributions can use during installation. * * Use when: - * - Installing a host feature into `PluginHost` - * - Registering session API namespaces, kits, resources, capabilities, or lifecycle hooks + * - Installing a host feature into `ExtensionHost` + * - Registering kits, resources, or capabilities * * Expects: - * - Installation happens during `PluginHost` construction - * - Session API namespace names are unique across all contributions and built-in namespaces + * - Installation happens during `ExtensionHost` construction * * Returns: - * - Registration helpers that keep `PluginHost` generic while allowing extensions + * - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features */ -export interface PluginHostInstallContext { - registerSessionApi: (namespace: string, factory: PluginSessionApiFactory) => void - registerLifecycleHook: (event: PluginHostLifecycleEvent, hook: PluginHostLifecycleHook) => void +export interface ExtensionHostInstallContext { registerKit: (kit: KitDescriptor) => KitDescriptor unregisterKit: (kitId: string) => KitDescriptor | undefined setResourceResolver: (key: string, resolver: () => Promise | T) => void @@ -547,89 +416,10 @@ export interface PluginHostInstallContext { } /** - * Describes the context passed into one contribution-owned session API factory. + * Installs one generic host feature into `ExtensionHost`. * * Use when: - * - Creating a custom namespace that will be attached to `session.apis` - * - * Expects: - * - `session` refers to the plugin session currently being assembled - * - `assertPermission` is called inside contribution methods before privileged work - * - * Returns: - * - The context needed to build one session API namespace - */ -export interface PluginSessionApiFactoryContext { - host: PluginHostInstallContext - session: PluginHostSessionContext - assertPermission: (input: PluginHostPermissionRequest) => void -} - -/** - * Builds one custom session API namespace installed by a host contribution. - * - * Use when: - * - Extending `session.apis` with a plugin-host-specific namespace - * - * Expects: - * - The returned value is an object-like namespace safe to expose to plugin code - * - * Returns: - * - The namespace object attached to `session.apis[namespace]` - */ -export type PluginSessionApiFactory = (context: PluginSessionApiFactoryContext) => TNamespace - -/** - * Enumerates the host lifecycle moments contributions may observe. - * - * Use when: - * - Registering contribution hooks tied to session load, readiness, or stop events - * - * Expects: - * - Hooks are synchronous and should stay lightweight - * - * Returns: - * - The supported lifecycle event names for `registerLifecycleHook(...)` - */ -export type PluginHostLifecycleEvent = 'session-loaded' | 'session-ready' | 'session-stopped' - -/** - * Describes the context passed into one contribution lifecycle hook. - * - * Use when: - * - Reacting to a plugin session lifecycle event outside the generic host core - * - * Expects: - * - `session` and `manifest` refer to the active session at the time of the hook - * - * Returns: - * - The snapshot available to contribution lifecycle hooks - */ -export interface PluginHostLifecycleHookContext { - host: PluginHostInstallContext - session: PluginHostSessionContext - manifest: ManifestV1 -} - -/** - * Handles one contribution-owned lifecycle event emitted by `PluginHost`. - * - * Use when: - * - A contribution needs to observe session loading, readiness, or teardown - * - * Expects: - * - Hooks are synchronous and should throw only for deterministic setup failures - * - * Returns: - * - No value; side effects are owned by the contribution - */ -export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext) => void - -/** - * Installs one generic host feature into `PluginHost`. - * - * Use when: - * - The host should register extra session APIs or bootstrap runtime-specific behavior + * - The host should register kits, resources, capabilities, or runtime-specific behavior * * Expects: * - Installation is idempotent for one host instance @@ -638,15 +428,15 @@ export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext) * Returns: * - No value; the contribution mutates the provided install context */ -export interface PluginHostContribution { - install: (context: PluginHostInstallContext) => void +export interface ExtensionHostContribution { + install: (context: ExtensionHostInstallContext) => void } /** - * Configures one `PluginHost.start(...)` or `PluginHost.init(...)` call. + * Configures one `ExtensionHost.start(...)` call. * * Use when: - * - Starting a session with runtime, compatibility, or capability-wait overrides + * - Starting a session with runtime or working-directory overrides * * Expects: * - Omitted fields fall back to host defaults or method-local defaults @@ -654,17 +444,9 @@ export interface PluginHostContribution { * Returns: * - Per-start overrides for initialization behavior */ -export interface PluginStartOptions { +export interface ExtensionStartOptions { /** Working directory used to resolve relative manifest entrypoints. */ cwd?: string /** Runtime override used for this specific start operation. */ runtime?: PluginRuntime - /** Whether initialization should stop in configuration-needed instead of auto-readying. */ - requireConfiguration?: boolean - /** Compatibility ranges sent during protocol negotiation. */ - compatibility?: Omit - /** Capability keys that must become ready before the session can proceed. */ - requiredCapabilities?: string[] - /** Wait timeout applied to each required capability. @default 15000 */ - capabilityWaitTimeoutMs?: number } diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-define-extension-entrypoint.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-define-extension-entrypoint.ts new file mode 100644 index 000000000..aad387692 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-define-extension-entrypoint.ts @@ -0,0 +1,8 @@ +import { defineExtension } from '../../extension' + +export default defineExtension({ + id: 'test-define-extension-entrypoint', + async setup(ctx) { + await ctx.modules.register({ id: 'defined-extension-module' }) + }, +}) diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts index 779fb9175..86b3fdc90 100644 --- a/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts @@ -1,42 +1,46 @@ -import type { ContextInit } from '../../plugin/shared' +import type { KitRef } from '../../kit' + +import { defineExtension } from '../../extension' /** - * Exercises host-injected plugin APIs during initialization. + * Exercises host-injected extension kit APIs during setup. * * Use when: - * - Verifying that a plugin can consume injected kit and binding APIs - * - Testing end-to-end plugin host bindings from a real plugin entrypoint + * - Verifying that an extension can consume injected kit APIs + * - Testing end-to-end extension host bindings from a real extension entrypoint * * Expects: - * - The host exposes `kit.widget` to the plugin runtime - * - The manifest grants the plugin read and write permissions for the relevant resources + * - The host exposes `kit.widget.test` to the extension runtime + * - The manifest grants the extension permission to use the kit * * Returns: - * - Resolves after persisting the observed host state into a dynamic module config + * - Resolves after persisting the observed host state into a dynamic binding config */ -export async function init({ apis }: ContextInit): Promise { - const kits = await apis.kits.list() - const widgetCapabilities = await apis.kits.getCapabilities('kit.widget') - - await apis.bindings.announce({ - moduleId: 'test-injected-host-apis-module', - kitId: 'kit.widget', - kitModuleType: 'window', - config: { - route: '/widgets/injected-host-apis', - }, - }) - - await apis.bindings.activate({ - moduleId: 'test-injected-host-apis-module', - }) - - await apis.bindings.update({ - moduleId: 'test-injected-host-apis-module', - config: { - route: '/widgets/injected-host-apis', - observedKitIds: kits.map(kit => kit.kitId), - observedCapabilityKeys: widgetCapabilities.map(capability => capability.key).sort(), - }, - }) +interface TestWidgetKitClient { + mount: () => void } + +export const testWidgetKit = { + id: 'kit.widget.test', + version: '1.0.0', + createClient() { + return { + mount() {}, + } + }, +} satisfies KitRef + +export default defineExtension({ + id: 'test-plugin-injected-host-apis', + async setup(ctx) { + const module = await ctx.modules.register({ + id: 'test-injected-host-apis-module', + permissions: { + apis: [{ key: testWidgetKit.id, actions: ['invoke'] }], + }, + }) + + const widgets = await module.kits.use(testWidgetKit) + widgets.mount() + }, +}) diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-invalid-extension-entrypoint.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-invalid-extension-entrypoint.ts new file mode 100644 index 000000000..09b5598e1 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-invalid-extension-entrypoint.ts @@ -0,0 +1 @@ +export const notAnExtension = true diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts index dfe9f1140..6b39384bb 100644 --- a/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts @@ -1,5 +1,8 @@ -import type { ContextInit } from '../../plugin/shared' +import { defineExtension } from '../../extension' -export async function init(_initContext: ContextInit) { - return false -} +export default defineExtension({ + id: 'test-plugin-no-connect', + setup() { + throw new Error('Plugin initialization aborted by plugin: test-plugin-no-connect') + }, +}) diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts index 784e4126c..2d93fa318 100644 --- a/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts @@ -1,16 +1,6 @@ -import type { ContextInit } from '../../plugin/shared' +import { defineExtension } from '../../extension' -import { defineEventa } from '@moeru/eventa' - -export async function init({ channels }: ContextInit): Promise { - channels.host.emit(defineEventa('vitest-call:init'), undefined) -} - -export async function configure(): Promise { - -} - -export async function setupModules({ apis, channels }: ContextInit): Promise { - const providerList = await apis.providers.listProviders() - channels.host.emit(defineEventa('vitest-call:setup-modules'), providerList) -} +export default defineExtension({ + id: 'test-plugin', + setup() {}, +}) diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts new file mode 100644 index 000000000..f910c220f --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-stoppable-extension-entrypoint.ts @@ -0,0 +1,16 @@ +import { defineExtension } from '../../extension' + +export const disposedSessionIds: string[] = [] + +export default defineExtension({ + id: 'test-stoppable-extension-entrypoint', + async setup(ctx) { + ctx.subscriptions.add({ + dispose() { + disposedSessionIds.push(ctx.extension.sessionId) + }, + }) + + await ctx.modules.register({ id: 'stoppable-extension-module' }) + }, +}) diff --git a/packages/plugin-sdk/src/plugin-host/transports/index.ts b/packages/plugin-sdk/src/plugin-host/transports/index.ts index 6e131f689..e058c658e 100644 --- a/packages/plugin-sdk/src/plugin-host/transports/index.ts +++ b/packages/plugin-sdk/src/plugin-host/transports/index.ts @@ -1,5 +1,5 @@ /** - * Describes the transport selected for one plugin host session. + * Describes the transport selected for one extension host session. * * Use when: * - Creating a plugin context for a specific runtime diff --git a/packages/plugin-sdk/src/plugin/apis/client/index.ts b/packages/plugin-sdk/src/plugin/apis/client/index.ts index bf98afb8f..fe22df9d7 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/index.ts @@ -2,12 +2,10 @@ import type { EventContext } from '@moeru/eventa' import type { BindingClientBindings } from './bindings' import type { KitClientBindings } from './kits' -import type { ToolClientBindings } from './tools' import { createBindings } from './bindings' import { createKits } from './kits' import { createResources } from './resources' -import { createTools } from './tools' /** * Collects the host-provided callbacks that back the plugin client API surface. @@ -24,28 +22,26 @@ import { createTools } from './tools' export interface PluginApiBindings { kits?: KitClientBindings bindings?: BindingClientBindings - tools?: ToolClientBindings } /** * Creates the low-level plugin API surface exposed to plugin code. * * Use when: - * - Building `ContextInit.apis` for a plugin session + * - Building host-backed client APIs for kit runtimes * * Expects: - * - `ctx` is the Eventa context for the current plugin session + * - `ctx` is the Eventa context for the current extension session * - `bindings` contains the host-backed callbacks for each enabled API group * * Returns: - * - The composed built-in plugin client APIs for resources, kits, bindings, and tools + * - The composed built-in plugin client APIs for resources, kits, and bindings */ export function createApis(ctx: EventContext, bindings: PluginApiBindings = {}) { return { ...createResources(ctx), kits: createKits(ctx, bindings.kits), bindings: createBindings(ctx, bindings.bindings), - tools: createTools(ctx, bindings.tools), } } @@ -53,7 +49,7 @@ export function createApis(ctx: EventContext, bindings: PluginApiBindi * Describes the concrete API object returned by {@link createApis}. * * Use when: - * - Typing `ContextInit.apis` + * - Typing host-backed client APIs for kit runtimes * * Expects: * - The caller uses the same shape as the runtime-created API object @@ -65,4 +61,3 @@ export type PluginApis = ReturnType export * from './bindings' export * from './kits' export * from './resources' -export * from './tools' diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts index 21f45924d..188659ad5 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts @@ -9,7 +9,7 @@ import { createProviders } from './providers' * - Building the plugin SDK API object for a specific session * * Expects: - * - `ctx` is the Eventa context for the current plugin session + * - `ctx` is the Eventa context for the current extension session * * Returns: * - The resource client groups currently supported by the SDK diff --git a/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts b/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts deleted file mode 100644 index f06606cd4..000000000 --- a/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { EventContext } from '@moeru/eventa' - -import type { PluginToolDefinitionRecord, PluginToolsetPromptDefinitionRecord } from '../../../../plugin-host/shared' - -/** - * Identifies the bound API call used to register plugin tools. - * - * Use when: - * - Declaring permissions for `apis.tools.register()` - * - * Expects: - * - Host and plugin agree on this event name - * - * Returns: - * - The permission/event key string for tool registration - */ -export const pluginToolApiRegisterEventName = 'proj-airi:plugin-sdk:apis:client:tools:register' -/** - * Identifies the shared resource namespace that stores plugin tool records. - * - * Use when: - * - Declaring write permissions for tool registration - * - * Expects: - * - The host stores tool definitions under this resource key - * - * Returns: - * - The resource key string for the tool registry - */ -export const pluginToolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools' - -/** - * Carries a low-level plugin tool registration request into the host. - * - * Use when: - * - A plugin has already normalized its tool metadata and JSON Schema - * - * Expects: - * - `tool` is serializable and validated by the caller - * - `execute` accepts JSON-compatible input from the host - * - * Returns: - * - A registration payload consumed by the bound host implementation - */ -export interface RegisterToolInput { - tool: PluginToolDefinitionRecord - availability?: () => Promise | boolean - execute: (input: unknown) => Promise | unknown -} - -/** - * Carries one low-level toolset prompt registration request into the host. - * - * Use when: - * - A plugin wants shared model-facing guidance for a group of tools - * - * Expects: - * - `id` is stable within the owning plugin - * - * Returns: - * - A registration payload consumed by the bound host implementation - */ -export type RegisterToolsetPromptInput = PluginToolsetPromptDefinitionRecord - -/** - * Defines the host-side callbacks needed by the low-level plugin tool client. - * - * Use when: - * - Wiring plugin session APIs to host-owned registries - * - * Expects: - * - `register` stores or forwards the tool definition in the host - * - * Returns: - * - The bound client methods used by {@link createTools} - */ -export interface ToolClientBindings { - register: (input: RegisterToolInput) => Promise | void - registerToolsetPrompt: (input: RegisterToolsetPromptInput) => Promise | void -} - -function createMissingBindingError(method: string) { - return new Error(`Plugin tool API binding missing for \`${method}\`.`) -} - -function requireBinding(binding: TBinding | undefined, method: string): TBinding { - if (!binding) { - throw createMissingBindingError(method) - } - - return binding -} - -/** - * Creates the low-level plugin tool client surface exposed on `session.apis`. - * - * Use when: - * - Building the plugin SDK API object for a specific session - * - * Expects: - * - `bindings` comes from a host that knows how to store tool registrations - * - * Returns: - * - A minimal `tools.register(...)` client - */ -export function createTools(_ctx: EventContext, bindings?: ToolClientBindings) { - return { - async register(input: RegisterToolInput) { - return await requireBinding(bindings, 'tools.register').register(input) - }, - async registerToolsetPrompt(input: RegisterToolsetPromptInput) { - return await requireBinding(bindings, 'tools.registerToolsetPrompt').registerToolsetPrompt(input) - }, - } -} - -/** - * Describes the concrete client object returned by {@link createTools}. - * - * Use when: - * - Typing `apis.tools` - * - * Expects: - * - The caller uses the same method set as the runtime-created tools client - * - * Returns: - * - The inferred tools client surface - */ -export type ToolClient = ReturnType diff --git a/packages/plugin-sdk/src/plugin/define.ts b/packages/plugin-sdk/src/plugin/define.ts deleted file mode 100644 index e5612fac3..000000000 --- a/packages/plugin-sdk/src/plugin/define.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Plugin } from './shared' - -/** - * Declares a lazily constructed plugin definition with stable metadata. - * - * Use when: - * - A plugin entrypoint wants to expose metadata and deferred setup together - * - * Expects: - * - `setup` returns a {@link Plugin} object when the host loads the entrypoint - * - * Returns: - * - A serializable plugin definition that loaders can recognize and execute - */ -export function definePlugin(name: string, version: string, setup: () => Promise | Plugin): { - name: string - version: string - setup: () => Promise | Plugin -} { - return { - name, - version, - setup, - } -} diff --git a/packages/plugin-sdk/src/plugin/index.ts b/packages/plugin-sdk/src/plugin/index.ts index 55e675d85..5914b704d 100644 --- a/packages/plugin-sdk/src/plugin/index.ts +++ b/packages/plugin-sdk/src/plugin/index.ts @@ -1,2 +1 @@ export * from './apis' -export * from './define' diff --git a/packages/plugin-sdk/src/plugin/shared.ts b/packages/plugin-sdk/src/plugin/shared.ts deleted file mode 100644 index 075a87390..000000000 --- a/packages/plugin-sdk/src/plugin/shared.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ChannelHost } from '../channels/shared' -import type { PluginApis } from './apis/client' - -/** - * Describes the host-provided context injected into plugin hooks. - * - * Use when: - * - Implementing `Plugin.init` - * - Implementing `Plugin.setupModules` - * - * Expects: - * - `channels.host` is the control-plane Eventa context for the session - * - `apis` contains the host-bound plugin API surface for that session - * - * Returns: - * - A stable bootstrap object shared across plugin lifecycle hooks - */ -export interface ContextInit { - channels: { - host: ChannelHost - } - apis: PluginApis -} - -/** - * Defines the hook surface implemented by a plugin module. - * - * Use when: - * - Exporting plugin behavior from a runtime entrypoint - * - * Expects: - * - Hooks are optional, but at least one meaningful hook should be provided by a real plugin - * - * Returns: - * - A plugin lifecycle object consumed by the plugin host loader - */ -export interface Plugin { - /** - * Performs plugin initialization against the injected host context. - * - * Use when: - * - The plugin needs to announce state, wait for capabilities, or register resources during boot - * - * Expects: - * - The host has already created the plugin session and bound `initContext` - * - * Returns: - * - `false` to abort startup, or nothing to continue initialization - */ - init?: (initContext: ContextInit) => Promise - /** - * Declares additional modules or bindings after basic initialization. - * - * Use when: - * - The plugin wants to expose dynamic bindings after its initial boot logic - * - * Expects: - * - The host has already created the plugin session and bound `initContext` - * - * Returns: - * - Nothing. The host observes any side effects performed through `initContext.apis`. - */ - setupModules?: (initContext: ContextInit) => Promise -} diff --git a/packages/scenarios-stage-tamagotchi-electron/src/index.ts b/packages/scenarios-stage-tamagotchi-electron/src/index.ts index 10286d365..d48065167 100644 --- a/packages/scenarios-stage-tamagotchi-electron/src/index.ts +++ b/packages/scenarios-stage-tamagotchi-electron/src/index.ts @@ -1,4 +1,7 @@ export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket' export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces' export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog' +export { default as pluginChessWidgetFlowScenario } from './scenarios/plugin-chess-widget-flow' +export { default as pluginChessWorkerSmokeScenario } from './scenarios/plugin-chess-worker-smoke' +export { default as pluginWidgetStaticAssetsLocalAddressScenario } from './scenarios/plugin-widget-static-assets-local-address' export { default as settingsConnectionScenario } from './scenarios/settings-connection' diff --git a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-widget-flow.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-widget-flow.ts new file mode 100644 index 000000000..d93bb8cae --- /dev/null +++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-widget-flow.ts @@ -0,0 +1,262 @@ +import type { ScenarioContext } from '@proj-airi/vishot-runner-electron' + +import { defineScenario } from '@proj-airi/vishot-runner-electron' + +type ElectronApplication = ScenarioContext['electronApp'] +type Page = Parameters[1] +type Frame = ReturnType + +const pluginName = 'airi-plugin-game-chess' +const pluginModuleId = 'chess-like-main' +const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2) +const spawnedWidgetPattern = /Spawned widget/i +const whitespacePattern = /\s+/g +const pluginExtensionFramePath = `/_airi/extensions/${pluginName}/sessions/` + +function inferRouteFromUrl(url: string): string { + const hashIndex = url.indexOf('#') + if (hashIndex === -1) { + return '' + } + + const hash = url.slice(hashIndex + 1) + if (!hash) { + return '/' + } + + return hash.startsWith('/') ? hash : `/${hash}` +} + +function normalizeRoutePath(route: string): string { + if (!route) { + return '' + } + const queryIndex = route.indexOf('?') + if (queryIndex >= 0) { + return route.slice(0, queryIndex) + } + return route +} + +async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + let lastSeenWindows = '' + while (Date.now() < deadline) { + for (const page of electronApp.windows()) { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + const routePath = normalizeRoutePath(route) + // NOTICE: `/settings/devtools/widgets-calling` also contains `"/widgets"`, + // so this must match the exact widgets route/title to avoid selecting + // the devtools settings page by mistake. + if (title === 'Widgets' || routePath === '/widgets') { + return page + } + } + const snapshots = await Promise.all( + electronApp.windows().map(async (page) => { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}` + }), + ) + lastSeenWindows = snapshots.join('\n') + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`) +} + +async function getPageText(page: Page) { + return await page.locator('body').textContent().catch(() => '') ?? '' +} + +function excerpt(text: string, maxLength = 1800) { + const normalized = text.replaceAll(whitespacePattern, ' ').trim() + if (normalized.length <= maxLength) { + return normalized + } + return `${normalized.slice(0, maxLength)}...` +} + +function normalizeWhitespace(text: string) { + return text.replaceAll(whitespacePattern, ' ').trim() +} + +async function waitForCondition( + check: () => Promise, + timeoutMs: number, + failureMessage: () => Promise | string, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await check()) { + return + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + + const message = typeof failureMessage === 'string' + ? failureMessage + : await failureMessage() + throw new Error(message) +} + +async function waitForPluginReady(pluginHostPage: Page) { + await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(pluginName) + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => (await getPageText(pluginHostPage)).includes(pluginName), + 15_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin not discovered in Plugin Host: ${pluginName}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) + + await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(pluginName) + await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click() + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => { + const text = await getPageText(pluginHostPage) + return text.includes('Loaded Plugins') + && text.includes(pluginName) + && text.includes('phase:ready') + }, + 20_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin did not reach ready phase. Expected plugin=${pluginName}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) +} + +async function waitForSpawnedWidget(widgetsCallingPage: Page) { + await waitForCondition( + async () => { + const text = await getPageText(widgetsCallingPage) + return spawnedWidgetPattern.test(text) + }, + 15_000, + async () => { + const text = await getPageText(widgetsCallingPage) + if (text.includes(`Plugin manifest not found: ${pluginName}`)) { + return `Widget spawn failed: Plugin manifest not found: ${pluginName}\nPage excerpt: ${excerpt(text)}` + } + if (text.includes(`Plugin module "${pluginModuleId}" is not registered.`)) { + return `Widget spawn failed: Plugin module "${pluginModuleId}" is not registered.\nPage excerpt: ${excerpt(text)}` + } + return `Widget spawn timed out. Page excerpt: ${excerpt(text)}` + }, + ) +} + +async function getFrameText(frame: Frame | null | undefined) { + if (!frame) { + return '' + } + return await frame.locator('body').textContent().catch(() => '') ?? '' +} + +async function waitForChessFrameContent(widgetsPage: Page) { + await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 }) + + await waitForCondition( + async () => { + const frame = widgetsPage.frames().find(candidate => + candidate.url().includes(pluginExtensionFramePath) + || candidate.url().startsWith('airi-plugin://'), + ) + const text = await getFrameText(frame) + if (text.includes('Not Found')) { + throw new Error(`Plugin iframe returned Not Found. frameUrl=${frame?.url() ?? 'unknown'} text=${excerpt(text)}`) + } + return text.includes('Match Setup') + }, + 20_000, + async () => { + const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null) + const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n') + const candidateFrame = widgetsPage.frames().find(frame => + frame.url().includes(pluginExtensionFramePath) + || frame.url().startsWith('airi-plugin://'), + ) + const frameText = await getFrameText(candidateFrame) + return [ + 'Timed out waiting for chess iframe content.', + `iframe src: ${iframeSrc ?? '(none)'}`, + `frame urls:\n${frameUrls}`, + `frame text excerpt: ${excerpt(frameText)}`, + ].join('\n') + }, + ) +} + +export default defineScenario({ + id: 'plugin-chess-widget-flow', + async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) { + const mainWindow = await stageWindows.waitFor('main') + await controlsIsland.waitForReady(mainWindow.page) + + await controlsIsland.expand(mainWindow.page) + const settings = await controlsIsland.openSettings(mainWindow.page) + + const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host') + await waitForPluginReady(pluginHostPage) + + const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling') + await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click() + await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps) + await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click() + await waitForSpawnedWidget(widgetsCallingPage) + await capture('plugin-chess-widget-settings-flow', widgetsCallingPage) + + const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000) + await capture('plugin-chess-widget-window-before-iframe', widgetsPage, { fullPage: true }) + await waitForChessFrameContent(widgetsPage) + await capture('plugin-chess-widget-window-setup', widgetsPage, { fullPage: true }) + const moduleFrame = widgetsPage.frameLocator('iframe') + + const gridMetrics = await moduleFrame.locator('button[title="a1"]').evaluate((node) => { + const rect = node.getBoundingClientRect() + return { + width: rect.width, + height: rect.height, + delta: Math.abs(rect.width - rect.height), + } + }) + if (gridMetrics.delta > 1) { + throw new Error( + `Chess grid square is not square enough (a1): width=${gridMetrics.width.toFixed(2)} height=${gridMetrics.height.toFixed(2)} delta=${gridMetrics.delta.toFixed(2)}`, + ) + } + + // Normalize mode for deterministic scenario playback. + await moduleFrame.getByRole('radio', { name: 'Vs AIRI' }).click().catch(() => undefined) + await moduleFrame.getByRole('button', { name: 'Start Game' }).click() + + // Verify gameplay is interactive and AI responds. + await moduleFrame.getByTitle('e2').click() + await moduleFrame.getByTitle('e4').click() + await widgetsPage.waitForTimeout(1800) + + const afterMoveText = normalizeWhitespace(await moduleFrame.locator('body').textContent().catch(() => '') ?? '') + if (afterMoveText.includes('AI Engine Minimax fallback') || afterMoveText.includes('Minimax fallback')) { + throw new Error(`AIRI engine fell back to minimax during scenario. Frame text excerpt: ${excerpt(afterMoveText)}`) + } + + await widgetsPage.waitForTimeout(500) + await capture('plugin-chess-widget-window', widgetsPage, { fullPage: true }) + }, +}) diff --git a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-worker-smoke.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-worker-smoke.ts new file mode 100644 index 000000000..d1c9c2cb3 --- /dev/null +++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-chess-worker-smoke.ts @@ -0,0 +1,356 @@ +import type { ScenarioContext } from '@proj-airi/vishot-runner-electron' + +import { defineScenario } from '@proj-airi/vishot-runner-electron' + +type ElectronApplication = ScenarioContext['electronApp'] +type Page = Parameters[1] +type Frame = NonNullable> + +const pluginName = 'airi-plugin-game-chess' +const pluginModuleId = 'chess-like-main' +const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2) +const whitespacePattern = /\s+/g +const stockfishJsPattern = /stockfish-18-lite-single-[\w-]+\.js/ +const stockfishWasmPattern = /stockfish-18-lite-single-[\w-]+\.wasm/ +const pluginExtensionFramePath = `/_airi/extensions/${pluginName}/sessions/` + +function inferRouteFromUrl(url: string): string { + const hashIndex = url.indexOf('#') + if (hashIndex === -1) { + return '' + } + + const hash = url.slice(hashIndex + 1) + if (!hash) { + return '/' + } + + return hash.startsWith('/') ? hash : `/${hash}` +} + +function normalizeRoutePath(route: string): string { + if (!route) { + return '' + } + const queryIndex = route.indexOf('?') + if (queryIndex >= 0) { + return route.slice(0, queryIndex) + } + return route +} + +function excerpt(text: string, maxLength = 1800) { + const normalized = text.replaceAll(whitespacePattern, ' ').trim() + if (normalized.length <= maxLength) { + return normalized + } + return `${normalized.slice(0, maxLength)}...` +} + +async function getPageText(page: Page) { + return await page.locator('body').textContent().catch(() => '') ?? '' +} + +async function waitForCondition( + check: () => Promise, + timeoutMs: number, + failureMessage: () => Promise | string, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await check()) { + return + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + + const message = typeof failureMessage === 'string' + ? failureMessage + : await failureMessage() + throw new Error(message) +} + +async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + let lastSeenWindows = '' + while (Date.now() < deadline) { + for (const page of electronApp.windows()) { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + const routePath = normalizeRoutePath(route) + if (title === 'Widgets' || routePath === '/widgets') { + return page + } + } + + const snapshots = await Promise.all( + electronApp.windows().map(async (page) => { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}` + }), + ) + lastSeenWindows = snapshots.join('\n') + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`) +} + +async function waitForPluginReady(pluginHostPage: Page) { + await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(pluginName) + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => (await getPageText(pluginHostPage)).includes(pluginName), + 15_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin not discovered in Plugin Host: ${pluginName}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) + + await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(pluginName) + await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click() + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => { + const text = await getPageText(pluginHostPage) + return text.includes('Loaded Plugins') + && text.includes(pluginName) + && text.includes('phase:ready') + }, + 20_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin did not reach ready phase. Expected plugin=${pluginName}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) +} + +function findChessFrame(widgetsPage: Page) { + return widgetsPage.frames().find(candidate => + candidate.url().includes(pluginExtensionFramePath) + || candidate.url().startsWith('airi-plugin://'), + ) +} + +async function waitForChessFrameContent(widgetsPage: Page): Promise { + await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 }) + + await waitForCondition( + async () => { + const frame = findChessFrame(widgetsPage) + const text = await frame?.locator('body').textContent().catch(() => '') ?? '' + return text.includes('Match Setup') + }, + 20_000, + async () => { + const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null) + const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n') + return [ + 'Timed out waiting for chess iframe content.', + `iframe src: ${iframeSrc ?? '(none)'}`, + `frame urls:\n${frameUrls}`, + ].join('\n') + }, + ) + + const frame = findChessFrame(widgetsPage) + if (!frame) { + throw new Error('Chess iframe frame not found after content wait.') + } + + return frame +} + +async function runWorkerSmoke(frame: Frame) { + return await frame.evaluate(async ({ stockfishJsPatternSource, stockfishWasmPatternSource }) => { + const stockfishJsPattern = new RegExp(stockfishJsPatternSource) + const stockfishWasmPattern = new RegExp(stockfishWasmPatternSource) + const moduleScript = document.querySelector('script[type="module"][src]') as HTMLScriptElement | null + if (!moduleScript?.src) { + return { ok: false, reason: 'Missing module script in iframe document' } as const + } + + const indexSource = await fetch(moduleScript.src).then(response => response.text()) + const jsMatch = indexSource.match(stockfishJsPattern)?.[0] + const wasmMatch = indexSource.match(stockfishWasmPattern)?.[0] + if (!jsMatch || !wasmMatch) { + return { + ok: false, + reason: 'Failed to locate stockfish asset names in index chunk', + moduleScriptSrc: moduleScript.src, + } as const + } + + const workerScriptUrl = new URL(jsMatch, moduleScript.src).toString() + const wasmUrl = new URL(wasmMatch, moduleScript.src).toString() + const workerUrl = `${workerScriptUrl}#${encodeURIComponent(wasmUrl)}` + const workerFetch = await fetch(workerScriptUrl).then((response) => { + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type') ?? '', + } + }).catch((error) => { + return { + ok: false, + status: -1, + contentType: String(error), + } + }) + const wasmFetch = await fetch(wasmUrl).then((response) => { + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type') ?? '', + } + }).catch((error) => { + return { + ok: false, + status: -1, + contentType: String(error), + } + }) + + let genericWorkerOk = false + let genericWorkerError = '' + try { + const genericUrl = URL.createObjectURL(new Blob(['onmessage=()=>postMessage("pong")'], { type: 'text/javascript' })) + const genericWorker = new Worker(genericUrl) + const genericResult = await new Promise<'ok' | 'timeout' | 'error'>((resolve) => { + const timer = setTimeout(resolve, 3_000, 'timeout') + genericWorker.addEventListener('message', () => { + clearTimeout(timer) + resolve('ok') + }) + genericWorker.addEventListener('error', () => { + clearTimeout(timer) + resolve('error') + }) + genericWorker.postMessage('ping') + }) + genericWorker.terminate() + URL.revokeObjectURL(genericUrl) + genericWorkerOk = genericResult === 'ok' + if (genericResult !== 'ok') { + genericWorkerError = `generic-worker-${genericResult}` + } + } + catch (error) { + genericWorkerError = String(error) + } + + const logs: string[] = [] + let workerError = '' + let ready = false + let done = false + + const worker = new Worker(workerUrl) + const timeout = setTimeout(() => { + done = true + }, 20_000) + + worker.addEventListener('message', (event) => { + const text = typeof event.data === 'string' ? event.data : String(event.data) + logs.push(text) + if (text.includes('uciok')) { + ready = true + done = true + } + }) + worker.addEventListener('error', (event) => { + workerError = event.message || 'worker error event' + done = true + }) + worker.addEventListener('messageerror', () => { + workerError = 'worker messageerror event' + done = true + }) + + const pingTimer = setInterval(() => { + try { + worker.postMessage('uci') + } + catch { + // ignore + } + }, 700) + + const deadline = Date.now() + 20_000 + for (;;) { + if (done || Date.now() >= deadline) { + break + } + await new Promise(resolve => setTimeout(resolve, 120)) + } + + clearInterval(pingTimer) + clearTimeout(timeout) + worker.terminate() + + return { + ok: ready, + workerUrl, + wasmUrl, + workerFetch, + wasmFetch, + genericWorkerOk, + genericWorkerError, + workerError, + logs, + } as const + }, { + stockfishJsPatternSource: stockfishJsPattern.source, + stockfishWasmPatternSource: stockfishWasmPattern.source, + }) +} + +export default defineScenario({ + id: 'plugin-chess-worker-smoke', + async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) { + const mainWindow = await stageWindows.waitFor('main') + await controlsIsland.waitForReady(mainWindow.page) + + await controlsIsland.expand(mainWindow.page) + const settings = await controlsIsland.openSettings(mainWindow.page) + + const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host') + await waitForPluginReady(pluginHostPage) + + const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling') + await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click() + await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps) + await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click() + + const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000) + const moduleFrame = await waitForChessFrameContent(widgetsPage) + const smoke = await runWorkerSmoke(moduleFrame) + await capture('plugin-chess-worker-smoke', widgetsPage, { fullPage: true }) + + if (!smoke.ok) { + throw new Error([ + 'Stockfish worker smoke test failed.', + `workerUrl: ${'workerUrl' in smoke ? smoke.workerUrl : '(missing)'}`, + `wasmUrl: ${'wasmUrl' in smoke ? smoke.wasmUrl : '(missing)'}`, + `workerFetch: ${'workerFetch' in smoke ? JSON.stringify(smoke.workerFetch) : '(missing)'}`, + `wasmFetch: ${'wasmFetch' in smoke ? JSON.stringify(smoke.wasmFetch) : '(missing)'}`, + `genericWorkerOk: ${'genericWorkerOk' in smoke ? String(smoke.genericWorkerOk) : '(missing)'}`, + `genericWorkerError: ${'genericWorkerError' in smoke ? smoke.genericWorkerError || '(none)' : '(missing)'}`, + `workerError: ${'workerError' in smoke ? smoke.workerError || '(none)' : '(missing)'}`, + `logs: ${'logs' in smoke ? excerpt((smoke.logs ?? []).join(' | ')) : '(none)'}`, + `reason: ${'reason' in smoke ? smoke.reason : '(none)'}`, + ].join('\n')) + } + }, +}) diff --git a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-widget-static-assets-local-address.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-widget-static-assets-local-address.ts new file mode 100644 index 000000000..010821adf --- /dev/null +++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/plugin-widget-static-assets-local-address.ts @@ -0,0 +1,279 @@ +import type { ScenarioContext } from '@proj-airi/vishot-runner-electron' + +import { defineScenario } from '@proj-airi/vishot-runner-electron' + +type ElectronApplication = ScenarioContext['electronApp'] +type Page = Parameters[1] +type Frame = ReturnType + +const extensionId = 'airi-plugin-game-chess' +const pluginModuleId = 'chess-like-main' +const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2) +const spawnedWidgetPattern = /Spawned widget/i +const whitespacePattern = /\s+/g +const extensionRoutePathPrefix = `/_airi/extensions/${extensionId}/sessions/` + +function inferRouteFromUrl(url: string): string { + const hashIndex = url.indexOf('#') + if (hashIndex === -1) { + return '' + } + + const hash = url.slice(hashIndex + 1) + if (!hash) { + return '/' + } + + return hash.startsWith('/') ? hash : `/${hash}` +} + +function normalizeRoutePath(route: string): string { + if (!route) { + return '' + } + + const queryIndex = route.indexOf('?') + if (queryIndex >= 0) { + return route.slice(0, queryIndex) + } + + return route +} + +function excerpt(text: string, maxLength = 1800) { + const normalized = text.replaceAll(whitespacePattern, ' ').trim() + if (normalized.length <= maxLength) { + return normalized + } + return `${normalized.slice(0, maxLength)}...` +} + +async function getPageText(page: Page) { + return await page.locator('body').textContent().catch(() => '') ?? '' +} + +async function waitForCondition( + check: () => Promise, + timeoutMs: number, + failureMessage: () => Promise | string, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await check()) { + return + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + + const message = typeof failureMessage === 'string' + ? failureMessage + : await failureMessage() + throw new Error(message) +} + +async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + let lastSeenWindows = '' + while (Date.now() < deadline) { + for (const page of electronApp.windows()) { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + const routePath = normalizeRoutePath(route) + if (title === 'Widgets' || routePath === '/widgets') { + return page + } + } + + const snapshots = await Promise.all( + electronApp.windows().map(async (page) => { + const title = await page.title().catch(() => '') + const url = page.url() + const route = inferRouteFromUrl(url) + return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}` + }), + ) + lastSeenWindows = snapshots.join('\n') + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`) +} + +async function ensurePluginEnabledAndLoaded(pluginHostPage: Page) { + await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(extensionId) + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => (await getPageText(pluginHostPage)).includes(extensionId), + 15_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin not discovered in Plugin Host: ${extensionId}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) + + const pluginTitle = pluginHostPage.locator('div.font-semibold', { hasText: extensionId }).first() + const pluginCard = pluginTitle.locator('xpath=ancestor::div[contains(@class, "rounded-xl")][1]') + + const hasDisabledChip = await pluginCard.getByText('disabled').first().isVisible().catch(() => false) + if (hasDisabledChip) { + await pluginCard.getByRole('button', { name: 'Enable' }).click() + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + } + + await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(extensionId) + await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click() + await pluginHostPage.getByRole('button', { name: 'Refresh' }).click() + + await waitForCondition( + async () => { + const text = await getPageText(pluginHostPage) + return text.includes('Loaded Plugins') + && text.includes(extensionId) + && text.includes('phase:ready') + }, + 20_000, + async () => { + const text = await getPageText(pluginHostPage) + return [ + `Plugin did not reach ready phase. Expected extension=${extensionId}`, + `Page excerpt: ${excerpt(text)}`, + ].join('\n') + }, + ) +} + +async function waitForSpawnedWidget(widgetsCallingPage: Page) { + await waitForCondition( + async () => { + const text = await getPageText(widgetsCallingPage) + return spawnedWidgetPattern.test(text) + }, + 15_000, + async () => { + const text = await getPageText(widgetsCallingPage) + if (text.includes(`Plugin manifest not found: ${extensionId}`)) { + return `Widget spawn failed: Plugin manifest not found: ${extensionId}\nPage excerpt: ${excerpt(text)}` + } + if (text.includes(`Plugin module "${pluginModuleId}" is not registered.`)) { + return `Widget spawn failed: Plugin module "${pluginModuleId}" is not registered.\nPage excerpt: ${excerpt(text)}` + } + return `Widget spawn timed out. Page excerpt: ${excerpt(text)}` + }, + ) +} + +async function getFrameText(frame: Frame | null | undefined) { + if (!frame) { + return '' + } + return await frame.locator('body').textContent().catch(() => '') ?? '' +} + +async function waitForExtensionFrame(widgetsPage: Page) { + await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 }) + + await waitForCondition( + async () => { + const frame = widgetsPage.frames().find(candidate => + candidate.url().includes(extensionRoutePathPrefix), + ) + return Boolean(frame) + }, + 20_000, + async () => { + const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null) + const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n') + return [ + 'Timed out waiting for extension iframe content.', + `iframe src: ${iframeSrc ?? '(none)'}`, + `frame urls:\n${frameUrls}`, + ].join('\n') + }, + ) + + const frame = widgetsPage.frames().find(candidate => + candidate.url().includes(extensionRoutePathPrefix), + ) + if (!frame) { + throw new Error(`Extension iframe frame not found with path prefix ${extensionRoutePathPrefix}.`) + } + return frame +} + +function assertExtensionAssetUrl(url: string, label: string) { + if (!url) { + throw new Error(`${label} is empty.`) + } + + const parsed = new URL(url) + if (parsed.protocol !== 'http:') { + throw new Error(`${label} must use http protocol, got ${parsed.protocol} (${url}).`) + } + if (parsed.hostname !== '127.0.0.1') { + throw new Error(`${label} must use 127.0.0.1, got ${parsed.hostname} (${url}).`) + } + if (!parsed.pathname.startsWith(extensionRoutePathPrefix) || !parsed.pathname.includes('/ui/')) { + throw new Error(`${label} must use ${extensionRoutePathPrefix}:assetSessionId/ui/... path, got ${parsed.pathname} (${url}).`) + } + if (parsed.searchParams.has('t')) { + throw new Error(`${label} must not contain legacy auth token query param "t", got ${url}.`) + } +} + +/** + * Captures and verifies plugin widget static asset loading through local extension HTTP endpoint. + * + * Use when: + * - Validating plugin-host + widget-calling devtools integration end-to-end + * - Verifying iframe static assets are served from local auth-protected extension routes + * + * Expects: + * - Chess plugin is discoverable by plugin host (`airi-plugin-game-chess`) + * - Widget module `chess-like-main` is available after plugin load + * + * Returns: + * - Visual captures for plugin host, widgets-calling, and widget iframe window + * - Runtime assertions that iframe URL/frame URL are `http://127.0.0.1:/_airi/extensions/.../sessions/.../ui/...` + */ +export default defineScenario({ + id: 'plugin-widget-static-assets-local-address', + async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) { + const mainWindow = await stageWindows.waitFor('main') + await controlsIsland.waitForReady(mainWindow.page) + + await controlsIsland.expand(mainWindow.page) + const settings = await controlsIsland.openSettings(mainWindow.page) + + const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host') + await ensurePluginEnabledAndLoaded(pluginHostPage) + + const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling') + await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click() + await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps) + await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click() + await waitForSpawnedWidget(widgetsCallingPage) + + const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000) + await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 }) + + const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src') + if (!iframeSrc) { + throw new Error('Widget iframe src is missing.') + } + assertExtensionAssetUrl(iframeSrc, 'Widget iframe src') + + const extensionFrame = await waitForExtensionFrame(widgetsPage) + assertExtensionAssetUrl(extensionFrame.url(), 'Widget iframe frame URL') + const extensionFrameText = await getFrameText(extensionFrame) + if (extensionFrameText.includes('Not Found') || extensionFrameText.includes('Unauthorized')) { + throw new Error(`Extension iframe served an error document: ${excerpt(extensionFrameText)}`) + } + + await capture('plugin-widget-static-assets-widget-window', widgetsPage, { fullPage: true }) + }, +}) diff --git a/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index 9914359d4..398a5dc0c 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -1,5 +1,7 @@ import type { DeliveryConfig, + ExtensionIdentity, + ExtensionModuleIdentity, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent, @@ -10,7 +12,7 @@ import type { RoutingPolicy, } from './middlewares' import type { ServerWsConsumerSelectionCandidate, ServerWsStickyAssignment } from './server-ws/core' -import type { AuthenticatedPeer, Peer } from './types' +import type { AuthenticatedPeer, Peer, RegisteredExtensionModule } from './types' import { Buffer } from 'node:buffer' import { timingSafeEqual } from 'node:crypto' @@ -284,7 +286,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => // === Registries & Orchestrators === const peerStore = createServerWsPeerStore() const peers = peerStore.peers - const peersByModule = new Map>() + const peersByModule = new Map>() const consumers = createConsumerOrchestrator() const heartbeatTtlMs = options?.heartbeat?.readTimeout ?? serverWsDefaultHeartbeatTtlMs const heartbeatMessage = options?.heartbeat?.message ?? MessageHeartbeat.Pong @@ -359,15 +361,26 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => peers.delete(id) unregisterModulePeer(peerInfo, 'heartbeat expired') } - else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false && peerInfo.name && peerInfo.identity) { + else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false) { // 5 consecutive misses — mark unhealthy peerInfo.healthy = false logger.withFields({ peer: id, peerName: peerInfo.name, missedHeartbeats: peerInfo.missedHeartbeats }).debug('heartbeat late, marking unhealthy') - broadcastToAuthenticated({ - type: 'registry:modules:health:unhealthy', - data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' }, - metadata: createEventMetadata(instanceId), - }) + + if (peerInfo.name && peerInfo.identity) { + broadcastToAuthenticated({ + type: 'registry:modules:health:unhealthy', + data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' }, + metadata: createEventMetadata(instanceId), + }) + } + + for (const module of peerInfo.extensionModules?.values() ?? []) { + broadcastToAuthenticated({ + type: 'registry:modules:health:unhealthy', + data: { name: module.name, identity: module.identity, reason: 'heartbeat late' }, + metadata: createEventMetadata(instanceId), + }) + } } } }, healthCheckIntervalMs) @@ -376,22 +389,49 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => } // === Module Registry & Consumer Management === - function registerModulePeer(p: AuthenticatedPeer, name: string, index?: number) { - if (!peersByModule.has(name)) { - peersByModule.set(name, new Map()) + function registerExtensionModulePeer(p: AuthenticatedPeer, module: RegisteredExtensionModule) { + p.extensionModules ??= new Map() + const previous = p.extensionModules.get(module.identity.id) + if (previous && previous.name !== module.name) { + unregisterExtensionModuleRegistration(p, previous, 'reannounced') } - const group = peersByModule.get(name)! - if (group.has(index)) { - // log instead of silent overwrite - logger.withFields({ name, index }).debug('peer replaced for module') + p.extensionModules.set(module.identity.id, module) + + if (!peersByModule.has(module.name)) { + peersByModule.set(module.name, new Map()) } + peersByModule.get(module.name)!.set(module.identity.id, p) p.healthy = true - group.set(index, p) broadcastRegistrySync() } + function findModulePeer(moduleName: string, moduleIndex: number | undefined, identity?: MetadataEventSource) { + if (isExtensionModuleIdentity(identity)) { + return peersByModule.get(moduleName)?.get(identity.id) + } + + // REVIEW: This keeps legacy indexed websocket module routing while extension modules move to identity keys. + if (typeof moduleIndex !== 'undefined') { + return peersByModule.get(moduleName)?.get(moduleIndex) + } + + const group = peersByModule.get(moduleName) + if (!group) { + return undefined + } + + // REVIEW: This preserves the old unindexed module bucket until server module routing is fully identity-based. + const legacyPeer = group.get(undefined) + if (legacyPeer) { + return legacyPeer + } + + const peers = [...group.values()] + return peers.length === 1 ? peers[0] : undefined + } + function registerConsumer(peerId: string, event: string, mode: ReturnType, group?: string, priority?: number) { consumers.register({ peerId, event, mode, group, priority }) } @@ -453,11 +493,11 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => } } - // broadcast module:de-announced to all authenticated peers + // broadcast extension:module:de-announced to all authenticated peers if (peerInfo.identity) { broadcastToAuthenticated({ - type: 'module:de-announced', - data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: options?.reason }, + type: 'extension:module:de-announced', + data: { name: peerInfo.name, identity: peerInfo.identity, possibleEvents: [], reason: options?.reason }, metadata: createEventMetadata(instanceId), }) } @@ -468,18 +508,80 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => broadcastRegistrySync() } + function unregisterExtensionModuleRegistration( + peerInfo: AuthenticatedPeer, + module: RegisteredExtensionModule, + reason?: string, + ) { + const group = peersByModule.get(module.name) + if (group?.get(module.identity.id) === peerInfo) { + group.delete(module.identity.id) + + if (group.size === 0) { + peersByModule.delete(module.name) + } + } + + peerInfo.extensionModules?.delete(module.identity.id) + broadcastToAuthenticated({ + type: 'extension:module:de-announced', + data: { name: module.name, identity: module.identity, possibleEvents: [], reason }, + metadata: createEventMetadata(instanceId), + }) + } + + function unregisterExtensionModuleRegistrations(peerInfo: AuthenticatedPeer, reason?: string) { + if (!peerInfo.extensionModules?.size) { + return + } + + for (const module of Array.from(peerInfo.extensionModules.values())) { + unregisterExtensionModuleRegistration(peerInfo, module, reason) + } + + peerInfo.extensionModules.clear() + broadcastRegistrySync() + } + function unregisterModulePeer(peerInfo: AuthenticatedPeer, reason?: string) { unregisterModuleRegistration(peerInfo, { reason }) + unregisterExtensionModuleRegistrations(peerInfo, reason) } function listKnownModules() { - return Array.from(peers.values()) + const legacyModules = Array.from(peers.values()) .filter(peerInfo => peerInfo.name && peerInfo.identity) .map(peerInfo => ({ name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity!, })) + + const extensionModules = Array.from(peers.values()).flatMap(peerInfo => + Array.from(peerInfo.extensionModules?.values() ?? []).map(module => ({ + name: module.name, + identity: module.identity, + })), + ) + + return [...legacyModules, ...extensionModules] + } + + function isExtensionIdentity(value: unknown): value is ExtensionIdentity { + return Boolean( + value + && typeof value === 'object' + && typeof (value as Partial).id === 'string', + ) + } + + function isExtensionModuleIdentity(value: unknown): value is ExtensionModuleIdentity { + return Boolean( + value + && typeof value === 'object' + && typeof (value as Partial).id === 'string' + && isExtensionIdentity((value as Partial).extension), + ) } // === Broadcasting & Registry Synchronization === @@ -566,7 +668,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => if (authenticatedPeer) { markPeerAlive(authenticatedPeer, { parentId: event.metadata?.event.id }) - if (authenticatedPeer.authenticated && event.metadata?.source) { + if (authenticatedPeer.authenticated && isExtensionModuleIdentity(event.metadata?.source)) { authenticatedPeer.identity = event.metadata.source } } @@ -610,56 +712,135 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => return } - case 'module:announce': { + case 'peer:authenticate': { + const clientToken = typeof event.data.token === 'string' ? event.data.token : '' + if (authToken && !timingSafeCompare(clientToken, authToken)) { + logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('peer authentication failed') + send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id)) + + return + } + + const authenticatedPeerId = event.data.peerId ?? peer.id + send(peer, RESPONSES.peerAuthenticated(authenticatedPeerId, event.metadata?.event.id)) + const p = peers.get(peer.id) + if (p) { + p.authenticated = true + p.peerIds ??= new Set() + p.peerIds.add(peer.id) + p.peerIds.add(authenticatedPeerId) + } + + sendRegistrySync(peer, event.metadata?.event.id) + + return + } + + case 'extension:authenticate': { + const clientToken = typeof event.data.token === 'string' ? event.data.token : '' + if (authToken && !timingSafeCompare(clientToken, authToken)) { + logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('extension authentication failed') + send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id)) + + return + } + + const p = peers.get(peer.id) + if (p) { + p.authenticated = true + p.extensionIdentity = event.data.identity + } + + send(peer, RESPONSES.extensionAuthenticated(event.data.identity, event.metadata?.event.id)) + sendRegistrySync(peer, event.metadata?.event.id) + + return + } + + case 'extension:announce': { const p = peers.get(peer.id) if (!p) { return } - const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource } - if (!name || typeof name !== 'string') { - send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid)) - - return - } - if (typeof index !== 'undefined') { - if (!Number.isInteger(index) || index < 0) { - send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIndexInvalid)) - - return - } - } - if (!identity || identity.kind !== 'plugin' || !identity.plugin?.id) { - send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid)) - - return - } if (authToken && !p.authenticated) { send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing)) return } - unregisterModuleRegistration(p, { - reason: 're-announcing', - unregisterConsumers: false, + if (!isExtensionIdentity(event.data.identity)) { + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid)) + + return + } + + p.extensionIdentity = event.data.identity + + send(peer, { + type: 'extension:announced', + data: event.data, + metadata: createEventMetadata(instanceId, event.metadata?.event.id), }) - p.name = name - p.index = index - p.identity = identity - - registerModulePeer(p, name, index) - - // broadcast module:announced to all authenticated peers for (const other of peers.values()) { - // only send to - // 1. authenticated peers - // 2. other peers except the announcing peer itself if (other.authenticated && !(other.peer.id === peer.id)) { send(other.peer, { - type: 'module:announced', - data: { name, index, identity }, + type: 'extension:announced', + data: event.data, + metadata: createEventMetadata(instanceId, event.metadata?.event.id), + }) + } + } + + return + } + + case 'extension:module:announce': { + const p = peers.get(peer.id) + if (!p) { + return + } + + if (authToken && !p.authenticated) { + send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing)) + + return + } + + const { name, identity } = event.data + if (!name || typeof name !== 'string') { + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid)) + + return + } + + if (!isExtensionModuleIdentity(identity)) { + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid)) + + return + } + + if (p.extensionIdentity && identity.extension.id !== p.extensionIdentity.id) { + send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid)) + + return + } + + p.extensionIdentity = identity.extension + registerExtensionModulePeer(p, { name, identity }) + + send(peer, { + type: 'extension:module:announced', + data: event.data, + metadata: createEventMetadata(instanceId, event.metadata?.event.id), + }) + + for (const other of peers.values()) { + if (other.authenticated && !(other.peer.id === peer.id)) { + send(other.peer, { + type: 'extension:module:announced', + data: event.data, metadata: createEventMetadata(instanceId, event.metadata?.event.id), }) } @@ -675,7 +856,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => identity?: MetadataEventSource config?: Record } - const moduleName = data.moduleName ?? data.identity?.plugin?.id ?? '' + const moduleName = data.moduleName ?? (isExtensionModuleIdentity(data.identity) ? data.identity.id : '') ?? '' const moduleIndex = data.moduleIndex const config = data.config @@ -692,7 +873,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () => } } - const target = peersByModule.get(moduleName)?.get(moduleIndex) + const target = findModulePeer(moduleName, moduleIndex, data.identity) if (target) { send(target.peer, { type: 'module:configure', diff --git a/packages/server-runtime/src/middlewares/route.test.ts b/packages/server-runtime/src/middlewares/route.test.ts index 5446f8442..d5444888b 100644 --- a/packages/server-runtime/src/middlewares/route.test.ts +++ b/packages/server-runtime/src/middlewares/route.test.ts @@ -10,7 +10,9 @@ import { matchesLabelSelector, matchesLabelSelectors, matchesRouteExpression } f function createPeer(options: { id: string name: string - plugin?: string + peerIds?: string[] + extensionLabels?: Record + extension?: string instanceId?: string labels?: Record authenticated?: boolean @@ -23,13 +25,41 @@ function createPeer(options: { remoteAddress: '127.0.0.1', }, authenticated: options.authenticated ?? true, + peerIds: options.peerIds ? new Set(options.peerIds) : undefined, name: options.name, - identity: options.plugin && options.instanceId - ? { kind: 'plugin', plugin: { id: options.plugin }, id: options.instanceId, labels: options.labels } + identity: options.extension && options.instanceId + ? { id: options.instanceId, extension: { id: options.extension }, labels: options.labels } + : undefined, + extensionIdentity: options.extensionLabels + ? { id: options.name, sessionId: `${options.id}-session`, labels: options.extensionLabels } : undefined, } } +function createExtensionModulePeer(): AuthenticatedPeer { + const peer = createPeer({ + id: 'peer-extension', + name: 'airi-extension-chess', + extension: 'airi-extension-chess', + instanceId: 'extension-session-1', + }) + + peer.extensionModules = new Map([ + ['chess-gamelet', { + name: 'character', + identity: { + id: 'chess-gamelet', + extension: { + id: 'airi-extension-chess', + sessionId: 'extension-session-1', + }, + }, + }], + ]) + + return peer +} + function createSparkNotifyEvent(overrides: Partial> = {}): WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any> { const data: WebSocketEvents['spark:notify'] = { id: 'evt-1', @@ -45,7 +75,7 @@ function createSparkNotifyEvent(overrides: Partial { const peer = createPeer({ id: 'peer-1', name: 'stage-ui', - plugin: 'stage-ui', + extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, }) @@ -136,7 +166,7 @@ describe('route middleware', () => { type: 'spark:notify', data: 'not-an-object', metadata: { - source: { kind: 'plugin', plugin: { id: 'server-runtime' }, id: 'test' }, + source: { id: 'test', extension: { id: 'server-runtime' } }, event: { id: 'evt-primitive' }, }, route: undefined, @@ -149,7 +179,7 @@ describe('route middleware', () => { const peer = createPeer({ id: 'peer-2', name: 'telegram-bot', - plugin: 'telegram-bot', + extension: 'telegram-bot', instanceId: 'telegram-1', labels: { app: 'telegram', env: 'prod' }, }) @@ -158,10 +188,56 @@ describe('route middleware', () => { expect(matchesDestinations(['label:env=dev'], peer)).toBe(false) }) + /** + * @example + * expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true) + */ + it('matches destinations by extension identity labels', () => { + const peer = createPeer({ + id: 'peer-extension-labels', + name: 'airi-extension', + extensionLabels: { surface: 'websocket-extension' }, + }) + + expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true) + expect(matchesRouteExpression({ type: 'label', selectors: ['surface=websocket-extension'] }, peer)).toBe(true) + expect(matchesDestinations(['label:surface=legacy-plugin'], peer)).toBe(false) + }) + + /** + * @example + * expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true) + */ + it('matches destinations by acknowledged peer id aliases', () => { + const peer = createPeer({ + id: 'runtime-peer-1', + name: 'stage-window', + peerIds: ['runtime-peer-1', 'stage-window'], + }) + + expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true) + expect(matchesDestinations([{ type: 'ids', ids: ['stage-window'] }], peer)).toBe(true) + expect(matchesDestinations(['peer:missing'], peer)).toBe(false) + }) + + /** + * @example + * expect(matchesDestinations(['module:character'], peer)).toBe(true) + */ + it('matches destinations by announced extension module name', () => { + const peer = createExtensionModulePeer() + + expect(matchesDestinations(['module:character'], peer)).toBe(true) + expect(matchesDestinations(['character'], peer)).toBe(true) + expect(matchesDestinations(['chess-*'], peer)).toBe(true) + expect(matchesDestinations(['module:missing'], peer)).toBe(false) + expect(matchesDestinations(['missing'], peer)).toBe(false) + }) + it('policy middleware filters targets', () => { const peers = new Map([ - ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], - ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })], + ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], + ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })], ]) const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] }) @@ -185,8 +261,8 @@ describe('route middleware', () => { it('policy middleware excludes unauthenticated peers', () => { const peers = new Map([ - ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], - ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })], + ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], + ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })], ]) const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] }) @@ -206,8 +282,8 @@ describe('route middleware', () => { it('policy middleware does not authorize bypass by itself', () => { const peers = new Map([ - ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], - ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })], + ['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })], + ['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })], ]) const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] }) @@ -229,7 +305,7 @@ describe('route middleware', () => { const peer = createPeer({ id: 'peer-3', name: 'debug-ui', - plugin: 'debug-ui', + extension: 'debug-ui', instanceId: 'debug-ui-1', labels: { devtools: 'true' }, }) diff --git a/packages/server-runtime/src/middlewares/route.ts b/packages/server-runtime/src/middlewares/route.ts index 76235e332..d33984af1 100644 --- a/packages/server-runtime/src/middlewares/route.ts +++ b/packages/server-runtime/src/middlewares/route.ts @@ -10,8 +10,8 @@ export type RouteDecision | { type: 'targets', targetIds: Set } export interface RoutingPolicy { - allowPlugins?: string[] - denyPlugins?: string[] + allowExtensions?: string[] + denyExtensions?: string[] allowLabels?: string[] denyLabels?: string[] } @@ -29,7 +29,7 @@ type DestinationList = Array function getPeerLabels(peer: AuthenticatedPeer) { return { - ...peer.identity?.plugin?.labels, + ...peer.extensionIdentity?.labels, ...peer.identity?.labels, } } @@ -71,13 +71,13 @@ export function peerMatchesPolicy(peer: AuthenticatedPeer, policy: RoutingPolicy return false } - const pluginId = peer.identity?.plugin?.id ?? '' + const extensionId = peer.identity?.extension.id ?? peer.extensionIdentity?.id ?? '' - if (policy.allowPlugins?.length && !policy.allowPlugins.includes(pluginId)) { + if (policy.allowExtensions?.length && !policy.allowExtensions.includes(extensionId)) { return false } - if (policy.denyPlugins?.length && policy.denyPlugins.includes(pluginId)) { + if (policy.denyExtensions?.length && policy.denyExtensions.includes(extensionId)) { return false } diff --git a/packages/server-runtime/src/middlewares/route/match-expression.ts b/packages/server-runtime/src/middlewares/route/match-expression.ts index ce481e3b6..de6ab45dc 100644 --- a/packages/server-runtime/src/middlewares/route/match-expression.ts +++ b/packages/server-runtime/src/middlewares/route/match-expression.ts @@ -39,11 +39,30 @@ export function matchesLabelSelectors(selectors: string[], labels: Record module.name === moduleName || module.identity.id === moduleName) +} + +function matchesExtensionModuleGlob(peer: AuthenticatedPeer, glob: string) { + return [...peer.extensionModules?.values() ?? []] + .some(module => matchesGlob(glob, module.name) || matchesGlob(glob, module.identity.id)) +} + +function matchesPeerId(peer: AuthenticatedPeer, peerId: string) { + return peer.peer.id === peerId || Boolean(peer.peerIds?.has(peerId)) +} + export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer): boolean { switch (expression.type) { case 'and': @@ -51,19 +70,19 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer: case 'or': return expression.any.some(expr => matchesRouteExpression(expr, peer)) case 'glob': { - const pluginId = peer.identity?.plugin?.id + const extensionId = getPeerExtensionId(peer) const matched = matchesGlob(expression.glob, peer.name) - || matchesGlob(expression.glob, pluginId) + || matchesGlob(expression.glob, extensionId) || matchesGlob(expression.glob, peer.identity?.id) return expression.inverted ? !matched : matched } case 'ids': { - const matched = expression.ids.includes(peer.peer.id) + const matched = expression.ids.some(peerId => matchesPeerId(peer, peerId)) return expression.inverted ? !matched : matched } case 'plugin': { - const matched = expression.plugins.includes(peer.identity?.plugin?.id ?? '') + const matched = expression.plugins.includes(getPeerExtensionId(peer) ?? '') return expression.inverted ? !matched : matched } case 'instance': { @@ -75,7 +94,7 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer: return expression.inverted ? !matched : matched } case 'module': { - const matched = expression.modules.includes(peer.name) + const matched = expression.modules.some(module => peer.name === module || matchesExtensionModule(peer, module)) return expression.inverted ? !matched : matched } case 'source': { @@ -101,22 +120,24 @@ export function matchesDestination(destination: string | RouteTargetExpression, switch (prefix) { case 'plugin': - return peer.identity?.plugin?.id === value + return getPeerExtensionId(peer) === value case 'instance': return peer.identity?.id === value case 'label': return matchesLabelSelectors([value], getPeerLabels(peer)) case 'peer': - return peer.peer.id === value + return matchesPeerId(peer, value) case 'module': - return peer.name === value + return peer.name === value || matchesExtensionModule(peer, value) case 'source': return peer.name === value default: { - const pluginId = peer.identity?.plugin?.id + const extensionId = getPeerExtensionId(peer) + // REVIEW: Bare/glob destination matching is kept for existing event payloads that do not use module:. return matchesGlob(destination, peer.name) - || matchesGlob(destination, pluginId) + || matchesGlob(destination, extensionId) || matchesGlob(destination, peer.identity?.id) + || matchesExtensionModuleGlob(peer, destination) } } } diff --git a/packages/server-runtime/src/server-ws/airi/index.test.ts b/packages/server-runtime/src/server-ws/airi/index.test.ts index 139d41c68..de27d48bb 100644 --- a/packages/server-runtime/src/server-ws/airi/index.test.ts +++ b/packages/server-runtime/src/server-ws/airi/index.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { AiriWebSocketEventFormatError, + createResponses, heartbeatFrameFrom, parseEvent, } from '.' @@ -75,4 +76,30 @@ describe('airi websocket protocol codec', () => { expect(heartbeatFrameFrom('pong')).toBe('pong') expect(heartbeatFrameFrom('{"type":"ping"}')).toBeUndefined() }) + + /** + * @example + * expect(responses.peerAuthenticated('peer-1').type).toBe('peer:authenticated') + * expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' }).type).toBe('extension:authenticated') + */ + it('creates peer and extension authentication responses separately', () => { + const responses = createResponses('server-1') + + expect(responses.peerAuthenticated('peer-1')).toMatchObject({ + type: 'peer:authenticated', + data: { + authenticated: true, + peerId: 'peer-1', + }, + }) + expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' })).toMatchObject({ + type: 'extension:authenticated', + data: { + authenticated: true, + identity: { + id: 'airi-extension-chess', + }, + }, + }) + }) }) diff --git a/packages/server-runtime/src/server-ws/airi/index.ts b/packages/server-runtime/src/server-ws/airi/index.ts index b4c5c7f28..00b4b0c80 100644 --- a/packages/server-runtime/src/server-ws/airi/index.ts +++ b/packages/server-runtime/src/server-ws/airi/index.ts @@ -1,4 +1,4 @@ -import type { DeliveryConfig, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types' +import type { DeliveryConfig, ExtensionIdentity, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types' import type { RouteContext, @@ -134,6 +134,20 @@ export function createResponses(serverInstanceId: string) { metadata: createEventMetadata(serverInstanceId, parentId), } satisfies WebSocketEvent> }, + peerAuthenticated(peerId: string, parentId?: string) { + return { + type: 'peer:authenticated', + data: { authenticated: true, peerId }, + metadata: createEventMetadata(serverInstanceId, parentId), + } satisfies WebSocketEvent> + }, + extensionAuthenticated(identity: ExtensionIdentity, parentId?: string) { + return { + type: 'extension:authenticated', + data: { identity, authenticated: true }, + metadata: createEventMetadata(serverInstanceId, parentId), + } satisfies WebSocketEvent> + }, notAuthenticated(parentId?: string) { return { type: 'error', diff --git a/packages/server-runtime/src/types/conn.ts b/packages/server-runtime/src/types/conn.ts index 888ef5402..0720f7ce4 100644 --- a/packages/server-runtime/src/types/conn.ts +++ b/packages/server-runtime/src/types/conn.ts @@ -1,4 +1,4 @@ -import type { MetadataEventSource } from '@proj-airi/server-shared/types' +import type { ExtensionIdentity, ExtensionModuleIdentity } from '@proj-airi/server-shared/types' export interface Peer { /** @@ -26,6 +26,16 @@ export interface NamedPeer { peer: Peer } +/** + * Tracks one module announced by an extension over a websocket peer. + */ +export interface RegisteredExtensionModule { + /** Human-readable module name used by registry sync and legacy routing lookup. */ + name: string + /** Module identity scoped to the owning extension session. */ + identity: ExtensionModuleIdentity +} + export enum WebSocketReadyState { CONNECTING = 0, OPEN = 1, @@ -35,7 +45,11 @@ export enum WebSocketReadyState { export interface AuthenticatedPeer extends NamedPeer { authenticated: boolean - identity?: MetadataEventSource + /** Caller-supplied peer ids acknowledged during manual peer authentication. */ + peerIds?: Set + identity?: ExtensionModuleIdentity + extensionIdentity?: ExtensionIdentity + extensionModules?: Map lastHeartbeatAt?: number healthy?: boolean missedHeartbeats?: number diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index ecfe7d56e..0b5f2a1c9 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -1,5 +1,6 @@ import type { - MetadataEventSource, + ExtensionIdentity, + ExtensionModuleIdentity, ModuleConfigSchema, ModuleDependency, WebSocketBaseEvent, @@ -49,10 +50,17 @@ export interface ClientOptions { name: string token?: string websocketConstructor?: WebSocketLikeConstructor + /** + * Selects the connection handshake owned by this client. + * + * @default 'module' + */ + handshake?: 'module' | 'manual' connectTimeoutMs?: number possibleEvents?: Array> - identity?: MetadataEventSource + extension?: ExtensionIdentity + identity?: ExtensionModuleIdentity dependencies?: ModuleDependency[] configSchema?: ModuleConfigSchema heartbeat?: ClientHeartbeatOptions @@ -122,7 +130,7 @@ export class Client { private connectionAttempt?: ConnectionAttempt private failureReason?: Error private status: ClientStatus = 'idle' - private readonly identity: MetadataEventSource + private readonly identity: ExtensionModuleIdentity private readonly heartbeat: Required private readonly websocketConstructor: WebSocketLikeConstructor @@ -139,10 +147,12 @@ export class Client { constructor(options: ClientOptions) { const { websocketConstructor, ...clientOptions } = options + const extension = options.extension ?? { + id: options.name, + } const identity = options.identity ?? { - kind: 'plugin', - plugin: { id: options.name }, id: createInstanceId(), + extension, } const heartbeat = normalizeHeartbeatOptions(options.heartbeat) @@ -162,7 +172,9 @@ export class Client { autoConnect: true, autoReconnect: true, maxReconnectAttempts: -1, + handshake: 'module', ...clientOptions, + extension, heartbeat, identity, } @@ -310,6 +322,7 @@ export class Client { } private async runConnectLoop() { + const reconnectingFromReady = this.pendingReconnect this.pendingReconnect = false while (!this.shouldClose) { @@ -317,7 +330,7 @@ export class Client { this.transitionTo(reconnecting ? 'reconnecting' : 'connecting') try { - await this.connectOnce() + await this.connectOnce({ reconnectingFromReady }) this.reconnectAttempts = 0 return } @@ -354,7 +367,7 @@ export class Client { throw new Error('Client is closed') } - private connectOnce(): Promise { + private connectOnce(options: { reconnectingFromReady?: boolean } = {}): Promise { const WebSocketConstructor = this.websocketConstructor const ws = new WebSocketConstructor(this.opts.url) this.websocket = ws @@ -450,6 +463,24 @@ export class Client { this.startHeartbeat() + if (this.opts.handshake === 'manual') { + if (options.reconnectingFromReady) { + attempt.authenticated = false + attempt.announced = false + this.reconnectAttempts = 0 + this.transitionTo('authenticating') + return + } + + attempt.authenticated = true + attempt.announced = true + this.reconnectAttempts = 0 + this.transitionTo('ready') + this.resolveAttempt() + this.opts.onReady?.() + return + } + if (this.opts.token) { attempt.authenticated = false this.transitionTo('authenticating') @@ -586,7 +617,7 @@ export class Client { private tryAnnounce() { this.sendOrThrow({ - type: 'module:announce', + type: 'extension:module:announce', data: { name: this.opts.name, identity: this.identity, @@ -691,7 +722,34 @@ export class Client { throw new Error('Authentication failed') } - case 'module:announced': { + case 'peer:authenticated': { + if (this.opts.handshake !== 'manual' || this.status !== 'authenticating' || !this.connectionAttempt) { + return + } + + if (data.data.authenticated) { + this.connectionAttempt.authenticated = true + this.transitionTo('announcing') + return + } + + throw new Error('Peer authentication failed') + } + + case 'extension:announced': { + if (this.opts.handshake !== 'manual' || this.status !== 'announcing' || !this.connectionAttempt) { + return + } + + this.connectionAttempt.announced = true + this.reconnectAttempts = 0 + this.transitionTo('ready') + this.resolveAttempt() + this.opts.onReady?.() + return + } + + case 'extension:module:announced': { if (!this.isSelfAnnouncement(data)) { return } @@ -713,7 +771,7 @@ export class Client { case 'registry:modules:sync': { // Fallback: If the status is stuck at 'announcing' but the sync already contains this module, - // it means the announce succeeded; the server simply didn't send back 'module:announced' + // it means the announce succeeded; the server simply didn't send back 'extension:module:announced' if (this.status !== 'announcing' || !this.connectionAttempt) { return } @@ -754,7 +812,7 @@ export class Client { } } - private isSelfAnnouncement(event: WebSocketBaseEvent<'module:announced', WebSocketEvents['module:announced']>) { + private isSelfAnnouncement(event: WebSocketBaseEvent<'extension:module:announced', WebSocketEvents['extension:module:announced']>) { return event.data.name === this.opts.name && event.data.identity?.id === this.identity.id } diff --git a/packages/server-sdk/src/extension-peer.ts b/packages/server-sdk/src/extension-peer.ts new file mode 100644 index 000000000..d4e5c2561 --- /dev/null +++ b/packages/server-sdk/src/extension-peer.ts @@ -0,0 +1,261 @@ +import type { + ExtensionIdentity, + ModuleConfigSchema, + ModuleDependency, + ModulePermissionDeclaration, + ProtocolEvents, + WebSocketBaseEvent, + WebSocketEventOptionalSource, + WebSocketEvents, +} from '@proj-airi/server-shared/types' + +import type { Client, ClientOptions, ConnectOptions } from './client' + +import { Client as WebSocketClient } from './client' + +/** + * Describes the client operations required by {@link WebSocketExtensionPeer}. + * + * @param C - Optional custom protocol event map carried by the websocket client. + */ +export interface ExtensionPeerClient { + /** Opens the underlying websocket client connection. */ + connect: (options?: ConnectOptions) => Promise + /** Sends one typed websocket event and reports whether it was accepted by the transport. */ + send: (data: WebSocketEventOptionalSource) => boolean + /** Sends one typed websocket event or throws when the transport is unavailable. */ + sendOrThrow: (data: WebSocketEventOptionalSource) => void + /** Closes the underlying websocket client connection. */ + close: () => void + /** Registers a typed event listener when backed by the standard server-sdk Client. */ + onEvent?: >( + event: E, + callback: (data: WebSocketBaseEvent[E]>) => void | Promise, + ) => () => void +} + +/** + * Describes one module announcement emitted through a websocket extension peer. + * + * @param C - Optional custom protocol event map used for possible event declarations. + */ +export interface AnnounceExtensionModuleInput { + /** Stable module id within the owning extension session. */ + id: string + /** Human-readable module name used by registry and diagnostics. */ + name: string + /** Protocol events this module may emit or handle. */ + possibleEvents?: Array> + /** Runtime permissions requested by this module. */ + permissions?: ModulePermissionDeclaration + /** Optional configuration schema understood by the module. */ + configSchema?: ModuleConfigSchema + /** Other modules or capabilities this module expects to exist. */ + dependencies?: ModuleDependency[] + /** Optional labels for routing, diagnostics, or inspector views. */ + labels?: Record +} + +/** + * Options for creating a websocket-backed extension peer. + * + * @param C - Optional custom protocol event map carried by the websocket client. + */ +export interface WebSocketExtensionPeerOptions { + /** Extension session identity announced after peer authentication. */ + extension: ExtensionIdentity + /** Optional prebuilt client used by tests or embedding runtimes. */ + client?: ExtensionPeerClient + /** Standard server-sdk Client options used when `client` is not supplied. */ + clientOptions?: Omit, 'name' | 'identity'> +} + +/** + * Provides extension-level protocol helpers over the existing websocket Client. + * + * Use when: + * - A remote extension talks to an AIRI host over websocket transport + * - Authoring/runtime code should say peer/extension/module explicitly instead of sending raw websocket events + * + * Expects: + * - The underlying client owns websocket lifecycle and serialization + * - The host interprets `peer:*`, `extension:*`, and `extension:module:*` protocol events + * + * Returns: + * - A thin transport peer that delegates connection and event sending to server-sdk Client + */ +export class WebSocketExtensionPeer { + private readonly client: ExtensionPeerClient + private readonly extension: ExtensionIdentity + + constructor(options: WebSocketExtensionPeerOptions) { + this.extension = options.extension + this.client = options.client ?? new WebSocketClient({ + ...options.clientOptions, + name: options.extension.id, + handshake: 'manual', + autoConnect: options.clientOptions?.autoConnect ?? false, + autoReconnect: options.clientOptions?.autoReconnect ?? false, + }) as Client + } + + /** + * Opens the underlying websocket connection. + * + * Use when: + * - The extension transport should begin peer authentication or announcement + * + * Expects: + * - The wrapped client can reach the configured websocket URL + * + * Returns: + * - Resolves when the wrapped client reports readiness + */ + connect(options?: ConnectOptions): Promise { + return this.client.connect(options) + } + + /** + * Sends transport-level peer authentication. + * + * Use when: + * - A websocket connection needs to authenticate before extension session grant + * + * Expects: + * - The websocket connection is already open or the client can queue/send immediately + * + * Returns: + * - Nothing; send failures are surfaced by the wrapped client + */ + authenticatePeer(input: { token?: string, peerId?: string } = {}): void { + this.client.sendOrThrow({ + type: 'peer:authenticate', + data: input, + }) + } + + /** + * Announces the extension session after peer authentication. + * + * Use when: + * - The remote peer has permission to enter extension setup + * + * Expects: + * - Permissions represent the extension-level ceiling grant or declaration snapshot + * + * Returns: + * - Nothing; send failures are surfaced by the wrapped client + */ + announceExtension(input: { permissions?: ModulePermissionDeclaration } = {}): void { + this.client.sendOrThrow({ + type: 'extension:announce', + data: { + identity: this.extension, + permissions: input.permissions, + }, + }) + } + + /** + * Announces one module registered by the current extension. + * + * Use when: + * - A websocket extension dynamically registers module capabilities + * + * Expects: + * - `id` is stable within this extension session + * + * Returns: + * - Nothing; send failures are surfaced by the wrapped client + */ + announceModule(input: AnnounceExtensionModuleInput): void { + this.client.sendOrThrow({ + type: 'extension:module:announce', + data: { + name: input.name, + identity: { + id: input.id, + extension: this.extension, + labels: input.labels, + }, + possibleEvents: input.possibleEvents ?? [], + permissions: input.permissions, + configSchema: input.configSchema, + dependencies: input.dependencies, + }, + }) + } + + /** + * Sends a typed websocket event through the wrapped client. + * + * Use when: + * - Runtime code has a protocol event not covered by helper methods + * + * Expects: + * - Callers pass a server-shared websocket event + * + * Returns: + * - Whether the event was accepted by the underlying transport + */ + send(data: WebSocketEventOptionalSource): boolean { + return this.client.send(data) + } + + /** + * Registers one event listener when the wrapped client supports typed listeners. + * + * Use when: + * - The remote extension needs to observe host protocol events + * + * Expects: + * - Test doubles may omit listener support + * + * Returns: + * - A disposer that removes the listener + */ + onEvent>( + event: E, + callback: (data: WebSocketBaseEvent[E]>) => void | Promise, + ): () => void { + if (!this.client.onEvent) { + throw new Error('Wrapped extension peer client does not support event listeners.') + } + + return this.client.onEvent(event, callback) + } + + /** + * Closes the underlying websocket client. + * + * Use when: + * - The extension transport is disposed + * + * Expects: + * - Close is idempotent in the wrapped client + * + * Returns: + * - Nothing + */ + close(): void { + this.client.close() + } +} + +/** + * Creates a websocket extension peer over server-sdk Client. + * + * Use when: + * - Code prefers a function factory over direct class construction + * + * Expects: + * - `extension.id` is the stable extension id + * + * Returns: + * - A {@link WebSocketExtensionPeer} ready to connect and announce + */ +export function createWebSocketExtensionPeer( + options: WebSocketExtensionPeerOptions, +): WebSocketExtensionPeer { + return new WebSocketExtensionPeer(options) +} diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index 7d00e73ea..954be7f61 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -1,4 +1,5 @@ export * from './client' +export * from './extension-peer' export type * from './websocket-like' export type * from '@proj-airi/server-shared/types' export { ContextUpdateStrategy, WebSocketEventSource } from '@proj-airi/server-shared/types' diff --git a/packages/server-sdk/test/client.test.ts b/packages/server-sdk/test/client.test.ts index 28c3bdd64..adc29039d 100644 --- a/packages/server-sdk/test/client.test.ts +++ b/packages/server-sdk/test/client.test.ts @@ -4,53 +4,61 @@ import superjson from 'superjson' import { afterEach, describe, expect, it, vi } from 'vitest' -class MockWebSocket { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - static readonly CLOSING = 2 - static readonly CLOSED = 3 +import { Client } from '../src/client' +import { createWebSocketExtensionPeer } from '../src/extension-peer' - static instances: MockWebSocket[] = [] +const { InjectedMockWebSocket, MockWebSocket } = vi.hoisted(() => { + class MockWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 - readonly sent: Array> = [] - readyState = MockWebSocket.CONNECTING - onclose?: () => void - onerror?: (event: { error?: Error } | unknown) => void - onmessage?: (event: { data: string | ArrayBufferLike | ArrayBufferView }) => void - onopen?: () => void + static instances: MockWebSocket[] = [] - constructor(public readonly url: string) { - MockWebSocket.instances.push(this) + readonly sent: Array> = [] + readyState = MockWebSocket.CONNECTING + onclose?: () => void + onerror?: (event: { error?: Error } | unknown) => void + onmessage?: (event: { data: string | ArrayBufferLike | ArrayBufferView }) => void + onopen?: () => void + + constructor(public readonly url: string) { + MockWebSocket.instances.push(this) + } + + send(data: string | ArrayBufferLike | ArrayBufferView) { + this.sent.push(data) + } + + close() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } + + ping() {} + pong() {} } - send(data: string | ArrayBufferLike | ArrayBufferView) { - this.sent.push(data) + class InjectedMockWebSocket extends MockWebSocket { + static instances: InjectedMockWebSocket[] = [] + + constructor(url: string) { + super(url) + InjectedMockWebSocket.instances.push(this) + } } - close() { - this.readyState = MockWebSocket.CLOSED - this.onclose?.() + return { + InjectedMockWebSocket, + MockWebSocket, } - - ping() {} - pong() {} -} - -class InjectedMockWebSocket extends MockWebSocket { - static instances: InjectedMockWebSocket[] = [] - - constructor(url: string) { - super(url) - InjectedMockWebSocket.instances.push(this) - } -} +}) vi.mock('crossws/websocket', () => ({ default: MockWebSocket, })) -const { Client } = await import('../src/client') - function lastSocket() { const socket = MockWebSocket.instances.at(-1) if (!socket) { @@ -60,7 +68,7 @@ function lastSocket() { return socket } -function parseSent(socket: MockWebSocket, index = -1) { +function parseSent(socket: InstanceType, index = -1) { const payload = socket.sent.at(index) if (!payload) { throw new Error(`No sent payload at index ${index}`) @@ -75,12 +83,12 @@ function parseSent(socket: MockWebSocket, index = -1) { return superjson.parse(decoded) } -function emitOpen(socket: MockWebSocket) { +function emitOpen(socket: InstanceType) { socket.readyState = MockWebSocket.OPEN socket.onopen?.() } -function emitMessage(socket: MockWebSocket, event: WebSocketEvent) { +function emitMessage(socket: InstanceType, event: WebSocketEvent) { socket.onmessage?.({ data: superjson.stringify(event), }) @@ -120,15 +128,15 @@ describe('client', () => { }, }) - const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'> expect(announceEvent).toMatchObject({ - type: 'module:announce', + type: 'extension:module:announce', data: { name: 'test-plugin' }, }) emitMessage(socket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: announceEvent.data.identity, @@ -202,10 +210,10 @@ describe('client', () => { } emitOpen(socket) - const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'> emitMessage(socket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: announceEvent.data.identity, @@ -219,6 +227,108 @@ describe('client', () => { await expect(connected).resolves.toBeUndefined() }) + it('supports manual handshake for extension peers without legacy module announce', async () => { + const client = new Client({ + autoConnect: false, + autoReconnect: false, + handshake: 'manual', + name: 'test-extension', + }) + + const connected = client.connect() + const socket = lastSocket() + + emitOpen(socket) + + await expect(connected).resolves.toBeUndefined() + expect(client.connectionStatus).toBe('ready') + expect(socket.sent).toHaveLength(0) + }) + + it('keeps manual reconnects non-ready until the peer reauthenticates and reannounces', async () => { + const onReady = vi.fn() + const client = new Client({ + autoConnect: false, + autoReconnect: true, + handshake: 'manual', + name: 'test-extension', + onReady, + }) + + const connected = client.connect() + const firstSocket = lastSocket() + + emitOpen(firstSocket) + + await expect(connected).resolves.toBeUndefined() + expect(client.connectionStatus).toBe('ready') + expect(onReady).toHaveBeenCalledTimes(1) + + firstSocket.close() + const secondSocket = lastSocket() + + emitOpen(secondSocket) + + expect(client.connectionStatus).toBe('authenticating') + expect(onReady).toHaveBeenCalledTimes(1) + + emitMessage(secondSocket, { + type: 'peer:authenticated', + data: { authenticated: true }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'peer-auth-1' }, + }, + }) + + expect(client.connectionStatus).toBe('announcing') + expect(onReady).toHaveBeenCalledTimes(1) + + emitMessage(secondSocket, { + type: 'extension:announced', + data: { + identity: { id: 'test-extension' }, + }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'extension-announce-1' }, + }, + }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + expect(client.connectionStatus).toBe('ready') + expect(onReady).toHaveBeenCalledTimes(2) + }) + + it('uses manual handshake when creating websocket extension peers', async () => { + const peer = createWebSocketExtensionPeer({ + extension: { + id: 'test-extension', + sessionId: 'session-1', + }, + clientOptions: { + autoReconnect: false, + }, + }) + + const connected = peer.connect() + const socket = lastSocket() + + emitOpen(socket) + await expect(connected).resolves.toBeUndefined() + + expect(socket.sent).toHaveLength(0) + + peer.authenticatePeer({ token: 'secret', peerId: 'peer-1' }) + expect(parseSent(socket)).toMatchObject({ + type: 'peer:authenticate', + data: { + token: 'secret', + peerId: 'peer-1', + }, + }) + }) + it('supports timeout-aware ensureConnected without cancelling the shared connect task', async () => { vi.useFakeTimers() @@ -236,10 +346,10 @@ describe('client', () => { await timedOutAssertion emitOpen(socket) - const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'> emitMessage(socket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: announceEvent.data.identity, @@ -285,10 +395,10 @@ describe('client', () => { emitOpen(socket) - const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'> emitMessage(socket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: announceEvent.data.identity, @@ -331,10 +441,10 @@ describe('client', () => { const secondSocket = lastSocket() emitOpen(secondSocket) - const announceEvent = parseSent(secondSocket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(secondSocket) as WebSocketEventOf<'extension:module:announce'> emitMessage(secondSocket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: announceEvent.data.identity, @@ -362,7 +472,7 @@ describe('client', () => { const socket = lastSocket() emitOpen(socket) - const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'> + const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'> const selfIdentity = announceEvent.data.identity @@ -378,7 +488,7 @@ describe('client', () => { }) emitMessage(socket, { - type: 'module:announced', + type: 'extension:module:announced', data: { name: 'test-plugin', identity: selfIdentity, diff --git a/packages/server-sdk/test/extension-peer.test.ts b/packages/server-sdk/test/extension-peer.test.ts new file mode 100644 index 000000000..3d76af1aa --- /dev/null +++ b/packages/server-sdk/test/extension-peer.test.ts @@ -0,0 +1,167 @@ +import type { WebSocketEventOptionalSource } from '@proj-airi/server-shared/types' + +import type { ExtensionPeerClient } from '../src/extension-peer' +import type { WebSocketLike } from '../src/websocket-like' + +import { describe, expect, it, vi } from 'vitest' + +import { createWebSocketExtensionPeer } from '../src/extension-peer' + +class FakeClient implements ExtensionPeerClient { + readonly sent: WebSocketEventOptionalSource[] = [] + readonly connect = vi.fn(async () => {}) + readonly close = vi.fn(() => {}) + + send(data: WebSocketEventOptionalSource): boolean { + this.sent.push(data) + return true + } + + sendOrThrow(data: WebSocketEventOptionalSource): void { + this.sent.push(data) + } +} + +class FakeSocket implements WebSocketLike { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + onopen?: () => void + onclose?: () => void + onmessage?: (event: { data: string }) => void + onerror?: (event: unknown) => void + readyState = FakeSocket.CONNECTING + readonly sent: string[] = [] + + constructor(readonly url: string) {} + + open() { + this.readyState = FakeSocket.OPEN + this.onopen?.() + } + + close(_code?: number, _reason?: string) { + this.readyState = FakeSocket.CLOSED + this.onclose?.() + } + + send(data: string | ArrayBufferLike | ArrayBufferView) { + this.sent.push(typeof data === 'string' ? data : new TextDecoder().decode(data)) + } +} + +describe('websocket extension peer', () => { + /** + * @example + * expect(fakeClient.sent.map(event => event.type)).toEqual(['peer:authenticate', 'extension:announce']) + */ + it('authenticates the websocket peer separately from the extension session', async () => { + const fakeClient = new FakeClient() + const peer = createWebSocketExtensionPeer({ + extension: { + id: 'airi-extension-chess', + version: '1.0.0', + sessionId: 'session-1', + }, + client: fakeClient, + }) + + await peer.connect() + peer.authenticatePeer({ token: 'secret', peerId: 'peer-1' }) + peer.announceExtension() + + expect(fakeClient.connect).toHaveBeenCalled() + expect(fakeClient.sent.map(event => event.type)).toEqual([ + 'peer:authenticate', + 'extension:announce', + ]) + expect(fakeClient.sent[0]).toMatchObject({ + type: 'peer:authenticate', + data: { + token: 'secret', + peerId: 'peer-1', + }, + }) + expect(fakeClient.sent[1]).toMatchObject({ + type: 'extension:announce', + data: { + identity: { + id: 'airi-extension-chess', + version: '1.0.0', + sessionId: 'session-1', + }, + }, + }) + }) + + /** + * @example + * expect(fakeClient.sent[0].type).toBe('extension:module:announce') + */ + it('announces extension modules under the owning extension identity', () => { + const fakeClient = new FakeClient() + const peer = createWebSocketExtensionPeer({ + extension: { + id: 'airi-extension-chess', + sessionId: 'session-1', + }, + client: fakeClient, + }) + + peer.announceModule({ + id: 'chess-gamelet', + name: 'Chess Gamelet', + possibleEvents: [], + }) + + expect(fakeClient.sent[0]).toMatchObject({ + type: 'extension:module:announce', + data: { + name: 'Chess Gamelet', + identity: { + id: 'chess-gamelet', + extension: { + id: 'airi-extension-chess', + sessionId: 'session-1', + }, + }, + possibleEvents: [], + }, + }) + }) + + /** + * @example + * expect(sockets).toHaveLength(1) + */ + it('does not reconnect by default because manual extension handshakes are one-shot', async () => { + const sockets: FakeSocket[] = [] + const peer = createWebSocketExtensionPeer({ + extension: { + id: 'airi-extension-chess', + sessionId: 'session-1', + }, + clientOptions: { + websocketConstructor: class extends FakeSocket { + constructor(url: string) { + super(url) + sockets.push(this) + } + }, + connectTimeoutMs: 10, + }, + }) + + const connectPromise = peer.connect() + expect(sockets).toHaveLength(1) + sockets[0]!.open() + await connectPromise + + sockets[0]!.close() + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(sockets).toHaveLength(1) + }) +}) diff --git a/packages/server-shared/src/errors.ts b/packages/server-shared/src/errors.ts index 597ef6c0b..faf439a6d 100644 --- a/packages/server-shared/src/errors.ts +++ b/packages/server-shared/src/errors.ts @@ -2,9 +2,9 @@ export const ServerErrorMessages = { invalidEventFormat: 'invalid event format', invalidToken: 'invalid token', mustAuthenticateBeforeAnnouncing: 'must authenticate before announcing', - moduleAnnounceIdentityInvalid: 'module identity must include kind=plugin and a plugin id for event \'module:announce\'', - moduleAnnounceIndexInvalid: 'the field \'index\' must be a non-negative integer for event \'module:announce\'', - moduleAnnounceNameInvalid: 'the field \'name\' must be a non-empty string for event \'module:announce\'', + moduleAnnounceIdentityInvalid: 'extension module identity must include an extension id for event \'extension:module:announce\'', + moduleAnnounceIndexInvalid: 'the field \'index\' must be a non-negative integer for event \'extension:module:announce\'', + moduleAnnounceNameInvalid: 'the field \'name\' must be a non-empty string for event \'extension:module:announce\'', moduleConsumerEventInvalid: 'the field \'event\' must be a non-empty string for event consumer registration', moduleNotFound: 'module not found, it hasn\'t announced itself or the name is incorrect', noConsumerRegistered: 'no consumer registered for requested event delivery', diff --git a/packages/server-shared/src/types/websocket/events.ts b/packages/server-shared/src/types/websocket/events.ts index 05fe69087..5ed7840a8 100644 --- a/packages/server-shared/src/types/websocket/events.ts +++ b/packages/server-shared/src/types/websocket/events.ts @@ -1,9 +1,9 @@ -import type { ModuleIdentity, ProtocolEvents, RouteConfig, WebSocketEventSource } from '@proj-airi/plugin-protocol/types' +import type { MetadataEventSource, ProtocolEvents, RouteConfig, WebSocketEventSource } from '@proj-airi/plugin-protocol/types' export * from '@proj-airi/plugin-protocol/types' export interface WebSocketEventBaseMetadata { - source?: ModuleIdentity + source?: MetadataEventSource event?: { id?: string parentId?: string @@ -18,7 +18,7 @@ export interface WebSocketBaseEvent { */ source?: WebSocketEventSource | S metadata: { - source: ModuleIdentity + source: MetadataEventSource event: { id: string parentId?: string diff --git a/packages/stage-pages/src/pages/settings/providers/index.vue b/packages/stage-pages/src/pages/settings/providers/index.vue index d3ba78de7..162290737 100644 --- a/packages/stage-pages/src/pages/settings/providers/index.vue +++ b/packages/stage-pages/src/pages/settings/providers/index.vue @@ -72,7 +72,7 @@ const allArtistryProvidersMetadata = computed(() => { }, ...(isCustomProvidersDisabled() ? [] - : [ + : ([ { id: 'replicate', category: 'artistry', @@ -103,7 +103,7 @@ const allArtistryProvidersMetadata = computed(() => { deployment: 'cloud', iconImage: undefined, }, - ]), + ] satisfies ProviderSourceCard[])), ] }) diff --git a/packages/stage-ui/src/stores/chat/context-prompt.test.ts b/packages/stage-ui/src/stores/chat/context-prompt.test.ts index 1d3c1beb2..c257da146 100644 --- a/packages/stage-ui/src/stores/chat/context-prompt.test.ts +++ b/packages/stage-ui/src/stores/chat/context-prompt.test.ts @@ -17,8 +17,7 @@ function makeContext(overrides: Record = {}): ContextSnapshot { metadata: { source: { id: 'system:minecraft-integration', - kind: 'plugin' as const, - plugin: { id: 'airi:minecraft' }, + extension: { id: 'airi:minecraft' }, }, }, ...overrides, diff --git a/packages/stage-ui/src/stores/chat/context-store.test.ts b/packages/stage-ui/src/stores/chat/context-store.test.ts index 83e6140cc..8647053e5 100644 --- a/packages/stage-ui/src/stores/chat/context-store.test.ts +++ b/packages/stage-ui/src/stores/chat/context-store.test.ts @@ -9,13 +9,12 @@ import { useChatContextStore } from './context-store' type TestContextMessage = ContextMessage & { source?: string } -function createMetadata(pluginId: string, instanceId: string): NonNullable { +function createMetadata(extensionId: string, moduleId: string): NonNullable { return { source: { - id: instanceId, - kind: 'plugin', - plugin: { - id: pluginId, + id: moduleId, + extension: { + id: extensionId, }, }, } diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts index 57ccc7f1c..566c38875 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts @@ -98,13 +98,12 @@ async function emitServerEvent(eventName: string, event: unknown) { await emitHooks(serverEventHooks.get(eventName) ?? [], event) } -function createMetadata(pluginId: string, instanceId: string) { +function createMetadata(extensionId: string, moduleId: string) { return { source: { - id: instanceId, - kind: 'plugin', - plugin: { - id: pluginId, + id: moduleId, + extension: { + id: extensionId, }, }, } @@ -128,7 +127,7 @@ function createContextUpdateEvent(overrides: Record = {}) { return { type: 'context:update', - source: 'plugin-module-host', + source: 'extension-module-host', metadata: createMetadata('weather', 'station-1'), data: { id, @@ -404,7 +403,7 @@ describe('context bridge contract', () => { await emitServerEvent('input:text', { type: 'input:text', - source: 'plugin-module-host', + source: 'extension-module-host', metadata: createMetadata('weather', 'station-1'), data: { text: 'hello', @@ -523,7 +522,7 @@ describe('context bridge contract', () => { await emitServerEvent('input:text', { type: 'input:text', - source: 'plugin-module-host', + source: 'extension-module-host', metadata: createMetadata('weather', 'station-1'), data: { text: 'hello', diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.ts index c4b7e86b4..47321590d 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.ts @@ -14,7 +14,7 @@ import { nanoid } from 'nanoid' import { defineStore, storeToRefs } from 'pinia' import { ref, toRaw, watch } from 'vue' -import { getEventSourceKey } from '../../../utils/event-source' +import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source' import { useCharacterOrchestratorStore } from '../../character' import { useChatOrchestratorStore } from '../../chat' import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '../../chat/constants' @@ -414,13 +414,13 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = contextId: event.contextId, eventId: event.id, textPreview: event.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id, + sourceLabel: getMetadataSourceLabel(event.metadata?.source), details: event, }) const ingestAttempt = ingestContextMessageSafely({ channel: 'broadcast', contextMessage: event, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id, + sourceLabel: getMetadataSourceLabel(event.metadata?.source), details: event, }) if (ingestAttempt.ok && ingestAttempt.result) { @@ -434,7 +434,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = eventId: event.id, mutation: ingestAttempt.result.mutation, textPreview: event.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id, + sourceLabel: getMetadataSourceLabel(event.metadata?.source), details: { entryCount: ingestAttempt.result.entryCount, event, @@ -506,7 +506,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = contextId: event.data.contextId, eventId: event.data.id, textPreview: event.data.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: event, }) const contextMessage: ContextMessage = { @@ -517,7 +517,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = const ingestAttempt = ingestContextMessageSafely({ channel: 'server', contextMessage, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: event, }) if (!ingestAttempt.ok) @@ -534,7 +534,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = eventId: contextMessage.id, mutation: ingestAttempt.result.mutation, textPreview: contextMessage.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: { entryCount: ingestAttempt.result.entryCount, event, @@ -551,7 +551,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = contextId: contextMessage.contextId, eventId: contextMessage.id, textPreview: contextMessage.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: contextMessage, }) })) @@ -586,7 +586,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = contextId: update.contextId, eventId: update.id, textPreview: update.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: { inputType: event.type, update, @@ -600,7 +600,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = const ingestAttempt = ingestContextMessageSafely({ channel: 'input', contextMessage, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: { inputType: event.type, update: contextMessage, @@ -622,7 +622,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () = eventId: contextMessage.id, mutation: ingestAttempt.result.mutation, textPreview: contextMessage.text, - sourceLabel: event.metadata?.source?.plugin?.id ?? event.metadata?.source?.id ?? event.source, + sourceLabel: getMetadataSourceLabel(event.metadata?.source) ?? event.source, details: { entryCount: ingestAttempt.result.entryCount, inputType: event.type, diff --git a/packages/stage-ui/src/stores/modules/gaming-minecraft.ts b/packages/stage-ui/src/stores/modules/gaming-minecraft.ts index 6fa5a7a5b..9624048f0 100644 --- a/packages/stage-ui/src/stores/modules/gaming-minecraft.ts +++ b/packages/stage-ui/src/stores/modules/gaming-minecraft.ts @@ -1,8 +1,9 @@ -import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk' +import type { MetadataEventSource, WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk' import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { getMetadataSourceLabel } from '../../utils/event-source' import { useModsServerChannelStore } from '../mods/api/channel-server' export interface MinecraftTrafficEntry { @@ -18,17 +19,15 @@ const RUNTIME_CONTEXT_TICK_MS = 1_000 const MAX_TRAFFIC_ENTRIES = 50 const MINECRAFT_SERVICE_NAME = 'minecraft-bot' -function getEventSourceLabel(event: { metadata?: { source?: { plugin?: { id?: string }, id?: string } } }) { - return event.metadata?.source?.plugin?.id - ?? event.metadata?.source?.id - ?? 'unknown' +function getEventSourceLabel(event: { metadata?: { source?: MetadataEventSource } }) { + return getMetadataSourceLabel(event.metadata?.source) ?? 'unknown' } -function isMinecraftSource(event: { metadata?: { source?: { plugin?: { id?: string }, id?: string } } }) { - const sourcePluginId = event.metadata?.source?.plugin?.id +function isMinecraftSource(event: { metadata?: { source?: MetadataEventSource } }) { + const sourceLabel = getMetadataSourceLabel(event.metadata?.source) const sourceId = event.metadata?.source?.id - return sourcePluginId === MINECRAFT_SERVICE_NAME || sourceId === MINECRAFT_SERVICE_NAME + return sourceLabel === MINECRAFT_SERVICE_NAME || sourceId === MINECRAFT_SERVICE_NAME } function summarizeContextUpdate(event: WebSocketBaseEvent<'context:update', WebSocketEvents['context:update']>) { @@ -46,8 +45,8 @@ function summarizeSparkCommand(event: WebSocketBaseEvent<'spark:command', WebSoc return `${event.data.intent} -> ${destinations}` } -function isMinecraftModuleIdentity(value: { name?: string, identity?: { plugin?: { id?: string } } }) { - return value.name === MINECRAFT_SERVICE_NAME || value.identity?.plugin?.id === MINECRAFT_SERVICE_NAME +function isMinecraftModuleIdentity(value: { name?: string, identity?: MetadataEventSource }) { + return value.name === MINECRAFT_SERVICE_NAME || getMetadataSourceLabel(value.identity) === MINECRAFT_SERVICE_NAME } export const useMinecraftStore = defineStore('minecraft', () => { diff --git a/packages/stage-ui/src/utils/event-source.ts b/packages/stage-ui/src/utils/event-source.ts index f3ea7cf16..47d9c64c9 100644 --- a/packages/stage-ui/src/utils/event-source.ts +++ b/packages/stage-ui/src/utils/event-source.ts @@ -5,14 +5,35 @@ interface EventSourcePayload { metadata?: { source?: MetadataEventSource } } -function formatMetadataSource(source?: MetadataEventSource) { - if (!source?.plugin) +/** + * Returns a human-readable source label for extension identities. + * + * Use when: + * - UI stores need to display or compare websocket event sources + * - Protocol metadata may come from extension, module, or kit peers + * + * Expects: + * - `source` is a protocol metadata identity from server-shared/server-sdk + * + * Returns: + * - A stable label, preferring extension-scoped module ids + */ +export function getMetadataSourceLabel(source?: MetadataEventSource) { + if (!source) return undefined - const pluginId = source.plugin.id - const instanceId = source.id + if ('extension' in source) { + return `${source.extension.id}:${source.id}` + } - return instanceId ? `${pluginId}:${instanceId}` : pluginId + return source.id +} + +function formatMetadataSource(source?: MetadataEventSource) { + if (!source) + return undefined + + return getMetadataSourceLabel(source) } export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') { diff --git a/plugins/airi-plugin-game-chess/package.json b/plugins/airi-plugin-game-chess/package.json index d2e527689..c366b6075 100644 --- a/plugins/airi-plugin-game-chess/package.json +++ b/plugins/airi-plugin-game-chess/package.json @@ -5,14 +5,17 @@ "private": true, "description": "Chess plugin for AIRI gamelet/widget runtime", "scripts": { + "dev:ui": "vite --host 127.0.0.1 --port 5174", "test": "vitest run --root . --config vitest.config.ts", "eval:run": "vieval run --root . --config ./vieval.config.ts", - "typecheck": "vue-tsc --noEmit -p tsconfig.json" + "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "build": "vite build && tsdown" }, "dependencies": { "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", "@proj-airi/plugin-protocol": "workspace:*", + "@proj-airi/plugin-sdk": "workspace:*", "@proj-airi/plugin-sdk-tamagotchi": "workspace:*", "@proj-airi/ui": "workspace:^", "animejs": "catalog:", @@ -25,7 +28,6 @@ "devDependencies": { "@ax-llm/ax": "catalog:", "@proj-airi/core-agent": "workspace:^", - "@proj-airi/plugin-sdk": "workspace:*", "@proj-airi/stage-ui": "workspace:^", "@proj-airi/unocss-preset-chromatic": "catalog:", "@unocss/reset": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 337201b91..80e3169a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1131,9 +1131,6 @@ catalogs: xsschema: specifier: 0.5.0-beta.2 version: 0.5.0-beta.2 - xstate: - specifier: ^5.30.0 - version: 5.30.0 yaml: specifier: ^2.8.3 version: 2.8.3 @@ -3595,18 +3592,12 @@ importers: '@proj-airi/plugin-protocol': specifier: workspace:* version: link:../plugin-protocol - '@proj-airi/server-shared': - specifier: workspace:* - version: link:../server-shared nanoid: specifier: 'catalog:' version: 5.1.11 valibot: specifier: 'catalog:' version: 1.3.1(typescript@5.9.3) - xstate: - specifier: 'catalog:' - version: 5.30.0 devDependencies: es-toolkit: specifier: 'catalog:' @@ -3620,6 +3611,9 @@ importers: '@proj-airi/plugin-sdk': specifier: workspace:* version: link:../plugin-sdk + nanoid: + specifier: 'catalog:' + version: 5.1.11 valibot: specifier: 'catalog:' version: 1.3.1(typescript@5.9.3) @@ -4895,6 +4889,9 @@ importers: '@proj-airi/plugin-protocol': specifier: workspace:* version: link:../../packages/plugin-protocol + '@proj-airi/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@proj-airi/plugin-sdk-tamagotchi': specifier: workspace:* version: link:../../packages/plugin-sdk-tamagotchi @@ -4926,9 +4923,6 @@ importers: '@proj-airi/core-agent': specifier: workspace:^ version: link:../../packages/core-agent - '@proj-airi/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk '@proj-airi/stage-ui': specifier: workspace:^ version: link:../../packages/stage-ui @@ -19149,9 +19143,6 @@ packages: zod-to-json-schema: optional: true - xstate@5.30.0: - resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -35062,8 +35053,6 @@ snapshots: zod: 4.3.6 zod-to-json-schema: 3.25.2(zod@4.3.6) - xstate@5.30.0: {} - xtend@4.0.2: {} xxhash-wasm@0.4.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 963aed065..c23d08a6b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -406,7 +406,6 @@ catalog: xast-util-to-xml: ^4.0.0 xastscript: ^4.0.0 xsschema: 0.5.0-beta.2 - xstate: ^5.30.0 yaml: ^2.8.3 yauzl: ^3.3.0 zod: ^4.3.6 @@ -418,11 +417,12 @@ catalogs: vitest: ^4.1.4 xsai: unspeech: ^0.1.14 + ignoredBuiltDependencies: - '@ax-llm/ax' - '@prisma/client' - better-sqlite3 - - simple-git-hooks # [workaround] postinstall script bundled in simple-git-hooks fails to execute with `enableGlobalVirtualStore: true`. Using local postinstall script to run `npx simple-git-hooks` instead. + - simple-git-hooks # [workaround] With `shellEmulator: true`, simple-git-hooks install may fail when no .git dir exists. onlyBuiltDependencies: - '@anthropic-ai/claude-code' @@ -452,6 +452,7 @@ onlyBuiltDependencies: - uiohook-napi - utf-8-validate - vue-demi + packageExtensions: '@formkit/auto-animate': peerDependencies: