From b61f14054a4f02c5e77bc41e471a336fecc62428 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:33:35 -0800 Subject: [PATCH 1/2] WIP: feat(omnium): Initial implementation --- packages/kernel-ui/package.json | 10 + packages/kernel-ui/src/hooks/index.test.ts | 17 + packages/kernel-ui/src/hooks/index.ts | 7 + packages/kernel-ui/vite.config.ts | 5 +- packages/omnium-gatherum/package.json | 2 + packages/omnium-gatherum/src/background.ts | 33 ++ .../src/services/capability-manager.ts | 229 +++++++++++++ .../src/services/caplet-bootstrap.ts | 179 ++++++++++ .../src/services/caplet-installer.test.ts | 161 +++++++++ .../src/services/caplet-installer.ts | 280 +++++++++++++++ .../src/services/caplet-registry.ts | 323 ++++++++++++++++++ .../src/services/host-service.ts | 162 +++++++++ .../src/services/storage.test.ts | 164 +++++++++ .../omnium-gatherum/src/services/storage.ts | 134 ++++++++ .../src/services/ui-renderer.ts | 186 ++++++++++ packages/omnium-gatherum/src/types/caplet.ts | 126 +++++++ packages/omnium-gatherum/src/ui/App.tsx | 4 +- .../src/ui/CapabilityManager.tsx | 181 ++++++++++ .../omnium-gatherum/src/ui/CapletStore.tsx | 141 ++++++++ packages/omnium-gatherum/src/ui/HostShell.tsx | 91 +++++ .../src/ui/InstalledCaplets.tsx | 183 ++++++++++ .../src/ui/caplet-ui-iframe.html | 83 +++++ packages/omnium-gatherum/vite.config.ts | 4 + yarn.lock | 8 + 24 files changed, 2711 insertions(+), 2 deletions(-) create mode 100644 packages/kernel-ui/src/hooks/index.test.ts create mode 100644 packages/kernel-ui/src/hooks/index.ts create mode 100644 packages/omnium-gatherum/src/services/capability-manager.ts create mode 100644 packages/omnium-gatherum/src/services/caplet-bootstrap.ts create mode 100644 packages/omnium-gatherum/src/services/caplet-installer.test.ts create mode 100644 packages/omnium-gatherum/src/services/caplet-installer.ts create mode 100644 packages/omnium-gatherum/src/services/caplet-registry.ts create mode 100644 packages/omnium-gatherum/src/services/host-service.ts create mode 100644 packages/omnium-gatherum/src/services/storage.test.ts create mode 100644 packages/omnium-gatherum/src/services/storage.ts create mode 100644 packages/omnium-gatherum/src/services/ui-renderer.ts create mode 100644 packages/omnium-gatherum/src/types/caplet.ts create mode 100644 packages/omnium-gatherum/src/ui/CapabilityManager.tsx create mode 100644 packages/omnium-gatherum/src/ui/CapletStore.tsx create mode 100644 packages/omnium-gatherum/src/ui/HostShell.tsx create mode 100644 packages/omnium-gatherum/src/ui/InstalledCaplets.tsx create mode 100644 packages/omnium-gatherum/src/ui/caplet-ui-iframe.html diff --git a/packages/kernel-ui/package.json b/packages/kernel-ui/package.json index 54192022f..e5845a5b2 100644 --- a/packages/kernel-ui/package.json +++ b/packages/kernel-ui/package.json @@ -28,6 +28,16 @@ "default": "./dist/index.cjs" } }, + "./hooks": { + "import": { + "types": "./dist/hooks/index.d.mts", + "default": "./dist/hooks.mjs" + }, + "require": { + "types": "./dist/hooks/index.d.cts", + "default": "./dist/hooks.cjs" + } + }, "./package.json": "./package.json", "./styles.css": "./dist/styles.css" }, diff --git a/packages/kernel-ui/src/hooks/index.test.ts b/packages/kernel-ui/src/hooks/index.test.ts new file mode 100644 index 000000000..4c2824416 --- /dev/null +++ b/packages/kernel-ui/src/hooks/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import * as indexModule from './index.ts'; + +describe('index', () => { + it('has the expected exports', () => { + expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'useDarkMode', + 'useStatusPolling', + 'useStream', + 'useRegistry', + 'useDatabase', + 'useKernelActions', + 'useVats', + ]); + }); +}); diff --git a/packages/kernel-ui/src/hooks/index.ts b/packages/kernel-ui/src/hooks/index.ts new file mode 100644 index 000000000..4afa74653 --- /dev/null +++ b/packages/kernel-ui/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useDarkMode } from './useDarkMode.ts'; +export { useDatabase } from './useDatabase.ts'; +export { useKernelActions } from './useKernelActions.ts'; +export { useRegistry } from './useRegistry.ts'; +export { useStatusPolling } from './useStatusPolling.ts'; +export { useStream } from './useStream.ts'; +export { useVats } from './useVats.ts'; diff --git a/packages/kernel-ui/vite.config.ts b/packages/kernel-ui/vite.config.ts index 3d5e8e745..3ab262f56 100644 --- a/packages/kernel-ui/vite.config.ts +++ b/packages/kernel-ui/vite.config.ts @@ -30,7 +30,10 @@ export default defineConfig(({ mode }) => { cssCodeSplit: false, cssMinify: !isDev, lib: { - entry: './src/index.ts', + entry: { + 'kernel-ui': './src/index.ts', + hooks: './src/hooks/index.ts', + }, name: 'KernelUI', formats: ['es', 'cjs'], fileName: (format, entryName) => { diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 69ddff48c..cacf5334d 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -42,6 +42,7 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@metamask/design-system-react": "^0.1.0", "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", @@ -51,6 +52,7 @@ "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "@metamask/utils": "^11.4.2", + "preact": "^10.27.2", "react": "^17.0.2", "react-dom": "^17.0.2", "ses": "^1.14.0" diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 7b2b07ba4..2c2de5934 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -7,6 +7,9 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { isJsonRpcResponse } from '@metamask/utils'; import type { JsonRpcResponse } from '@metamask/utils'; +import { hostService } from './services/host-service.ts'; +import { storageService } from './services/storage.ts'; + const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); let bootPromise: Promise | null = null; @@ -87,6 +90,36 @@ async function main(): Promise { 'background:', ); + // Initialize host service with kernel RPC access + hostService.initialize( + async (request) => { + await offscreenStream.write(request); + }, + (id, response) => { + rpcClient.handleResponse(id, response); + }, + ); + + // Load installed caplets and bootstrap them on startup + try { + const installedCaplets = await storageService.loadInstalledCaplets(); + for (const caplet of installedCaplets) { + if (caplet.enabled !== false && caplet.manifest.clusterConfig) { + try { + await hostService.bootstrapCaplet(caplet.id); + logger.log(`Bootstrapped caplet on startup: ${caplet.id}`); + } catch (error) { + logger.error( + `Failed to bootstrap caplet ${caplet.id} on startup`, + error, + ); + } + } + } + } catch (error) { + logger.error('Failed to bootstrap caplets on startup', error); + } + const ping = async (): Promise => { const result = await rpcClient.call('ping', []); logger.info(result); diff --git a/packages/omnium-gatherum/src/services/capability-manager.ts b/packages/omnium-gatherum/src/services/capability-manager.ts new file mode 100644 index 000000000..49e2ef756 --- /dev/null +++ b/packages/omnium-gatherum/src/services/capability-manager.ts @@ -0,0 +1,229 @@ +import { Logger } from '@metamask/logger'; +import type { KRef } from '@metamask/ocap-kernel'; + +import { storageService } from './storage.ts'; +import type { + CapabilityGrant, + CapabilityRequest, + InstalledCaplet, +} from '../types/caplet.ts'; + +const logger = new Logger('capability-manager'); + +/** + * Capability restrictions for attenuation. + */ +export type CapabilityRestrictions = { + expiresAt?: string; // ISO timestamp + scope?: string; // Scope identifier + rateLimit?: { + maxCalls: number; + windowMs: number; + }; +}; + +/** + * Capability manager service for tracking and managing capability grants. + */ +export class CapabilityManagerService { + /** + * Request a capability grant for a caplet. + * + * @param capletId - The caplet ID requesting the capability. + * @param capability - The capability request. + * @returns The capability request (for user approval). + */ + requestCapability( + capletId: string, + capability: CapabilityRequest, + ): CapabilityRequest { + logger.log(`Capability requested: ${capletId} -> ${capability.name}`); + return capability; + } + + /** + * Grant a capability to a caplet. + * + * @param capletId - The caplet ID to grant the capability to. + * @param capabilityName - The name of the capability. + * @param target - The target object reference (KRef) or service name. + * @param restrictions - Optional restrictions for capability attenuation. + * @returns The capability grant. + */ + async grantCapability( + capletId: string, + capabilityName: string, + target: KRef | string, + restrictions?: CapabilityRestrictions, + ): Promise { + logger.log(`Granting capability: ${capletId} -> ${capabilityName}`); + + const restrictionsObj = restrictions + ? { + ...(restrictions.expiresAt !== undefined && { + expiresAt: restrictions.expiresAt, + }), + ...(restrictions.scope !== undefined && { + scope: restrictions.scope, + }), + } + : undefined; + + const grant: CapabilityGrant = { + capletId, + capabilityName, + target: String(target), + grantedAt: new Date().toISOString(), + ...(restrictionsObj !== undefined && { restrictions: restrictionsObj }), + }; + + const grants = await storageService.loadCapabilityGrants(); + grants.push(grant); + await storageService.saveCapabilityGrants(grants); + + logger.log( + `Successfully granted capability: ${capletId} -> ${capabilityName}`, + ); + return grant; + } + + /** + * Revoke a capability from a caplet. + * + * @param capletId - The caplet ID to revoke the capability from. + * @param capabilityName - The name of the capability to revoke. + */ + async revokeCapability( + capletId: string, + capabilityName: string, + ): Promise { + logger.log(`Revoking capability: ${capletId} -> ${capabilityName}`); + + const grants = await storageService.loadCapabilityGrants(); + const filtered = grants.filter( + (grant) => + !( + grant.capletId === capletId && grant.capabilityName === capabilityName + ), + ); + + if (filtered.length === grants.length) { + throw new Error( + `Capability ${capabilityName} not found for caplet ${capletId}`, + ); + } + + await storageService.saveCapabilityGrants(filtered); + logger.log( + `Successfully revoked capability: ${capletId} -> ${capabilityName}`, + ); + } + + /** + * List all capabilities granted to a caplet. + * + * @param capletId - The caplet ID to list capabilities for. + * @returns Array of capability grants. + */ + async listCapabilities(capletId: string): Promise { + return await storageService.getCapabilityGrantsForCaplet(capletId); + } + + /** + * Get all capability grants. + * + * @returns Array of all capability grants. + */ + async getAllGrants(): Promise { + return await storageService.loadCapabilityGrants(); + } + + /** + * Check if a capability grant is still valid (not expired). + * + * @param grant - The capability grant to check. + * @returns True if the grant is valid. + */ + isGrantValid(grant: CapabilityGrant): boolean { + if (grant.restrictions?.expiresAt) { + const expiresAt = new Date(grant.restrictions.expiresAt); + return new Date() < expiresAt; + } + return true; + } + + /** + * Get valid capabilities for a caplet (filtering out expired grants). + * + * @param capletId - The caplet ID to get valid capabilities for. + * @returns Array of valid capability grants. + */ + async getValidCapabilities(capletId: string): Promise { + const grants = await this.listCapabilities(capletId); + return grants.filter((grant) => this.isGrantValid(grant)); + } + + /** + * Create an attenuated capability from an original capability. + * This creates a wrapper that applies restrictions. + * + * @param original - The original capability grant. + * @param restrictions - The restrictions to apply. + * @returns A new capability grant with restrictions applied. + */ + attenuateCapability( + original: CapabilityGrant, + restrictions: CapabilityRestrictions, + ): CapabilityGrant { + logger.log( + `Attenuating capability: ${original.capletId} -> ${original.capabilityName}`, + ); + + return { + ...original, + restrictions: { + ...original.restrictions, + ...(restrictions.expiresAt !== undefined && { + expiresAt: restrictions.expiresAt, + }), + ...(restrictions.scope !== undefined && { scope: restrictions.scope }), + }, + }; + } + + /** + * Revoke all capabilities for a caplet. + * + * @param capletId - The caplet ID to revoke all capabilities for. + */ + async revokeAllCapabilities(capletId: string): Promise { + logger.log(`Revoking all capabilities for caplet: ${capletId}`); + + const grants = await storageService.loadCapabilityGrants(); + const filtered = grants.filter((grant) => grant.capletId !== capletId); + + await storageService.saveCapabilityGrants(filtered); + logger.log(`Successfully revoked all capabilities for caplet: ${capletId}`); + } + + /** + * Get capabilities that a caplet requests but hasn't been granted yet. + * + * @param caplet - The installed caplet. + * @returns Array of ungranted capability requests. + */ + async getUngrantedCapabilities( + caplet: InstalledCaplet, + ): Promise { + const requested = caplet.manifest.capabilities?.requested ?? []; + const granted = await this.listCapabilities(caplet.id); + const grantedNames = new Set(granted.map((g) => g.capabilityName)); + + return requested.filter((req) => !grantedNames.has(req.name)); + } +} + +/** + * Singleton instance of the capability manager service. + */ +export const capabilityManagerService = new CapabilityManagerService(); diff --git a/packages/omnium-gatherum/src/services/caplet-bootstrap.ts b/packages/omnium-gatherum/src/services/caplet-bootstrap.ts new file mode 100644 index 000000000..0ca2c8a5e --- /dev/null +++ b/packages/omnium-gatherum/src/services/caplet-bootstrap.ts @@ -0,0 +1,179 @@ +import type { CapData } from '@endo/marshal'; +import { Logger } from '@metamask/logger'; +import type { ClusterConfig, KRef } from '@metamask/ocap-kernel'; + +import { capabilityManagerService } from './capability-manager.ts'; +import { storageService } from './storage.ts'; +import type { InstalledCaplet, CapabilityGrant } from '../types/caplet.ts'; + +const logger = new Logger('caplet-bootstrap'); + +/** + * Caplet bootstrap service for coordinating caplet initialization and capability injection. + */ +export class CapletBootstrapService { + /** + * Launch a caplet subcluster with capabilities injected. + * + * @param capletId - The caplet ID to bootstrap. + * @param clusterConfig - The cluster configuration (from manifest). + * @param launchKernelSubcluster - Function to launch the subcluster via kernel RPC. + * @returns The bootstrap result (CapData encoded result from bootstrap message). + */ + async bootstrapCaplet( + capletId: string, + clusterConfig: ClusterConfig, + launchKernelSubcluster: ( + config: ClusterConfig, + ) => Promise | null>, + ): Promise | null> { + logger.log(`Bootstrapping caplet: ${capletId}`); + + // Get installed caplet + const caplet = await storageService.getInstalledCaplet(capletId); + if (!caplet) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + // Get valid capabilities for this caplet + const grants = + await capabilityManagerService.getValidCapabilities(capletId); + + // Prepare cluster config with capabilities as kernel services + const enhancedConfig: ClusterConfig = { + ...clusterConfig, + services: [ + ...(clusterConfig.services ?? []), + // Add capability services if needed + // Capabilities are passed via bootstrap parameters + ], + }; + + // Launch subcluster + const result = await launchKernelSubcluster(enhancedConfig); + + // Store subcluster ID if we can extract it + // Note: The kernel returns the bootstrap result, not the subcluster ID directly + // We'll need to track this separately or get it from kernel status + + logger.log(`Successfully bootstrapped caplet: ${capletId}`); + return result; + } + + /** + * Inject capabilities into a running caplet. + * This sends capabilities to the caplet's root object via message. + * + * @param capletId - The caplet ID to inject capabilities into. + * @param capabilities - The capabilities to inject. + * @param queueMessage - Function to queue a message to a vat object. + */ + async injectCapabilities( + capletId: string, + capabilities: CapabilityGrant[], + queueMessage: ( + target: KRef, + method: string, + args: unknown[], + ) => Promise, + ): Promise { + logger.log(`Injecting capabilities into caplet: ${capletId}`); + + const caplet = await storageService.getInstalledCaplet(capletId); + if (!caplet) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + if (!caplet.subclusterId) { + throw new Error(`Caplet ${capletId} is not running (no subcluster ID)`); + } + + // Get the caplet's root object reference + const rootRef = await this.getCapletRoot(capletId); + if (!rootRef) { + throw new Error(`Could not get root object for caplet ${capletId}`); + } + + // Send capabilities to the caplet + // The caplet should have a method like `receiveCapabilities` or similar + await queueMessage(rootRef, 'receiveCapabilities', [capabilities]); + + logger.log(`Successfully injected capabilities into caplet: ${capletId}`); + } + + /** + * Get the root object reference for a caplet. + * + * @param capletId - The caplet ID. + * @param getRootObject - Function to get root object from kernel store. + * @returns The root object KRef, or undefined if not found. + */ + async getCapletRoot( + capletId: string, + getRootObject?: (vatId: string) => KRef | undefined, + ): Promise { + const caplet = await storageService.getInstalledCaplet(capletId); + if (!caplet?.subclusterId) { + return undefined; + } + + // If we have access to kernel store, get root object directly + if (getRootObject) { + // We need the vat ID, not subcluster ID + // This would require querying kernel status to get vat IDs for the subcluster + // For now, return undefined - this will be implemented when we have kernel access + return undefined; + } + + return undefined; + } + + /** + * Update the subcluster ID for an installed caplet. + * + * @param capletId - The caplet ID. + * @param subclusterId - The subcluster ID. + */ + async setCapletSubclusterId( + capletId: string, + subclusterId: string, + ): Promise { + const caplets = await storageService.loadInstalledCaplets(); + const caplet = caplets.find((c) => c.id === capletId); + + if (!caplet) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + caplet.subclusterId = subclusterId; + await storageService.saveInstalledCaplets(caplets); + + logger.log(`Set subcluster ID for caplet ${capletId}: ${subclusterId}`); + } + + /** + * Get capabilities to pass to a caplet during bootstrap. + * This prepares capabilities as objects that can be passed via bootstrap parameters. + * + * @param capletId - The caplet ID. + * @returns Map of capability names to targets (KRefs or service names). + */ + async getCapabilitiesForBootstrap( + capletId: string, + ): Promise> { + const grants = + await capabilityManagerService.getValidCapabilities(capletId); + const capabilities: Record = {}; + + for (const grant of grants) { + capabilities[grant.capabilityName] = grant.target; + } + + return capabilities; + } +} + +/** + * Singleton instance of the caplet bootstrap service. + */ +export const capletBootstrapService = new CapletBootstrapService(); diff --git a/packages/omnium-gatherum/src/services/caplet-installer.test.ts b/packages/omnium-gatherum/src/services/caplet-installer.test.ts new file mode 100644 index 000000000..cf2bf918d --- /dev/null +++ b/packages/omnium-gatherum/src/services/caplet-installer.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { CapletInstallerService } from './caplet-installer.ts'; +import * as registryModule from './caplet-registry.ts'; +import * as storageModule from './storage.ts'; +import type { CapletManifest } from '../types/caplet.ts'; + +vi.mock('./storage.ts'); +vi.mock('./caplet-registry.ts'); + +describe('CapletInstallerService', () => { + let installerService: CapletInstallerService; + let mockStorage: typeof storageModule.storageService; + let mockRegistry: typeof registryModule.capletRegistryService; + + beforeEach(() => { + installerService = new CapletInstallerService(); + mockStorage = storageModule.storageService; + mockRegistry = registryModule.capletRegistryService; + vi.clearAllMocks(); + }); + + describe('generateCapletId', () => { + it('generates caplet ID from manifest', () => { + const manifest: CapletManifest = { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }; + + const id = installerService.generateCapletId(manifest); + + expect(id).toBe('test@1.0.0'); + }); + }); + + describe('validateCaplet', () => { + it('validates a valid manifest', () => { + const manifest: CapletManifest = { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }; + + expect(() => installerService.validateCaplet(manifest)).not.toThrow(); + }); + + it('throws on invalid manifest', () => { + const invalidManifest = { + name: 'test', + // Missing required fields + }; + + expect(() => installerService.validateCaplet(invalidManifest)).toThrow(); + }); + + it('throws if bootstrap vat not found', () => { + const manifest: CapletManifest = { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'missing', + vats: { test: {} }, + }, + }; + + expect(() => installerService.validateCaplet(manifest)).toThrow(); + }); + }); + + describe('installCaplet', () => { + it('installs a caplet successfully', async () => { + const manifest: CapletManifest = { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }; + + vi.mocked(mockStorage.getInstalledCaplet).mockResolvedValue(undefined); + vi.mocked(mockStorage.loadInstalledCaplets).mockResolvedValue([]); + vi.mocked(mockStorage.saveInstalledCaplets).mockResolvedValue(); + vi.mocked(mockRegistry.fetchCapletBundle).mockResolvedValue(new Blob()); + + const result = await installerService.installCaplet(manifest); + + expect(result.id).toBe('test@1.0.0'); + expect(mockStorage.saveInstalledCaplets).toHaveBeenCalled(); + }); + + it('throws if caplet already installed', async () => { + const manifest: CapletManifest = { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }; + + vi.mocked(mockStorage.getInstalledCaplet).mockResolvedValue({ + id: 'test@1.0.0', + manifest, + installedAt: new Date().toISOString(), + enabled: true, + }); + + await expect(installerService.installCaplet(manifest)).rejects.toThrow(); + }); + }); + + describe('uninstallCaplet', () => { + it('uninstalls a caplet successfully', async () => { + const capletId = 'test@1.0.0'; + const caplets = [ + { + id: capletId, + manifest: { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }, + installedAt: new Date().toISOString(), + enabled: true, + }, + ]; + + vi.mocked(mockStorage.loadInstalledCaplets).mockResolvedValue(caplets); + vi.mocked(mockStorage.saveInstalledCaplets).mockResolvedValue(); + + await installerService.uninstallCaplet(capletId); + + expect(mockStorage.saveInstalledCaplets).toHaveBeenCalledWith([]); + }); + + it('throws if caplet not installed', async () => { + vi.mocked(mockStorage.loadInstalledCaplets).mockResolvedValue([]); + + await expect( + installerService.uninstallCaplet('missing@1.0.0'), + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/services/caplet-installer.ts b/packages/omnium-gatherum/src/services/caplet-installer.ts new file mode 100644 index 000000000..740ecad61 --- /dev/null +++ b/packages/omnium-gatherum/src/services/caplet-installer.ts @@ -0,0 +1,280 @@ +import { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; +import { is } from '@metamask/superstruct'; + +import { capletRegistryService } from './caplet-registry.ts'; +import { storageService } from './storage.ts'; +import type { + CapletManifest, + InstalledCaplet, + CapabilityRequest, +} from '../types/caplet.ts'; +import { CapletManifestStruct } from '../types/caplet.ts'; + +const logger = new Logger('caplet-installer'); + +/** + * User approvals for capability requests. + */ +export type CapabilityApprovals = Record; + +/** + * Caplet installer service for managing caplet installation lifecycle. + */ +export class CapletInstallerService { + /** + * Generate a unique caplet ID from manifest. + * + * @param manifest - The caplet manifest. + * @returns The caplet ID. + */ + generateCapletId(manifest: CapletManifest): string { + return `${manifest.name}@${manifest.version}`; + } + + /** + * Validate a caplet manifest. + * + * @param manifest - The manifest to validate. + * @throws If the manifest is invalid. + */ + validateCaplet(manifest: unknown): asserts manifest is CapletManifest { + if (!is(manifest, CapletManifestStruct)) { + throw new Error('Invalid caplet manifest structure'); + } + + // Validate that bundleSpec is accessible + // This is a basic check - actual bundle fetching happens during installation + if (!manifest.bundleSpec || typeof manifest.bundleSpec !== 'string') { + throw new Error('Invalid bundleSpec in manifest'); + } + + // Validate cluster config structure + if (!manifest.clusterConfig?.bootstrap) { + throw new Error('Invalid clusterConfig in manifest'); + } + + if ( + !manifest.clusterConfig.vats || + Object.keys(manifest.clusterConfig.vats).length === 0 + ) { + throw new Error('Cluster config must have at least one vat'); + } + + if (!manifest.clusterConfig.vats[manifest.clusterConfig.bootstrap]) { + throw new Error( + `Bootstrap vat '${manifest.clusterConfig.bootstrap}' not found in vats`, + ); + } + } + + /** + * Install a caplet with user capability approvals. + * + * @param manifest - The caplet manifest to install. + * @param userApprovals - User approvals for capability requests. + * @param launchKernelSubcluster - Function to launch the subcluster via kernel RPC. + * @returns The installed caplet metadata. + */ + async installCaplet( + manifest: CapletManifest, + userApprovals: CapabilityApprovals = {}, + launchKernelSubcluster?: (config: ClusterConfig) => Promise, + ): Promise { + logger.log(`Installing caplet: ${manifest.name}@${manifest.version}`); + + // Validate manifest + this.validateCaplet(manifest); + + // Check if already installed + const capletId = this.generateCapletId(manifest); + const existing = await storageService.getInstalledCaplet(capletId); + if (existing) { + throw new Error(`Caplet ${capletId} is already installed`); + } + + // Verify bundle is accessible (basic check) + try { + const source = manifest.registry?.source ?? 'url'; + await capletRegistryService.fetchCapletBundle( + manifest.bundleSpec, + source, + ); + } catch (error) { + throw new Error( + `Failed to fetch caplet bundle: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Create installed caplet record + const installedCaplet: InstalledCaplet = { + id: capletId, + manifest, + installedAt: new Date().toISOString(), + enabled: true, + }; + + // Launch subcluster if launch function provided + if (launchKernelSubcluster) { + try { + await launchKernelSubcluster(manifest.clusterConfig); + logger.log(`Launched subcluster for caplet ${capletId}`); + } catch (error) { + logger.error( + `Failed to launch subcluster for caplet ${capletId}`, + error, + ); + // Don't fail installation if subcluster launch fails - it can be launched later + } + } + + // Save to storage + const caplets = await storageService.loadInstalledCaplets(); + caplets.push(installedCaplet); + await storageService.saveInstalledCaplets(caplets); + + logger.log(`Successfully installed caplet: ${capletId}`); + return installedCaplet; + } + + /** + * Uninstall a caplet. + * + * @param capletId - The caplet ID to uninstall. + * @param terminateKernelSubcluster - Optional function to terminate the subcluster. + */ + async uninstallCaplet( + capletId: string, + terminateKernelSubcluster?: (subclusterId: string) => Promise, + ): Promise { + logger.log(`Uninstalling caplet: ${capletId}`); + + const caplets = await storageService.loadInstalledCaplets(); + const index = caplets.findIndex((c) => c.id === capletId); + + if (index === -1) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + const caplet = caplets[index]!; + + // Terminate subcluster if it exists and termination function provided + if (caplet.subclusterId && terminateKernelSubcluster) { + try { + await terminateKernelSubcluster(caplet.subclusterId); + logger.log( + `Terminated subcluster ${caplet.subclusterId} for caplet ${capletId}`, + ); + } catch (error) { + logger.error( + `Failed to terminate subcluster for caplet ${capletId}`, + error, + ); + // Continue with uninstallation even if termination fails + } + } + + // Remove from storage + caplets.splice(index, 1); + await storageService.saveInstalledCaplets(caplets); + + logger.log(`Successfully uninstalled caplet: ${capletId}`); + } + + /** + * Update a caplet to a new version. + * + * @param capletId - The caplet ID to update. + * @param newVersion - The new version to install. + * @param launchKernelSubcluster - Function to launch the subcluster via kernel RPC. + * @param terminateKernelSubcluster - Function to terminate the subcluster. + * @returns The updated installed caplet metadata. + */ + async updateCaplet( + capletId: string, + newVersion: string, + launchKernelSubcluster?: (config: ClusterConfig) => Promise, + terminateKernelSubcluster?: (subclusterId: string) => Promise, + ): Promise { + logger.log(`Updating caplet ${capletId} to version ${newVersion}`); + + const existing = await storageService.getInstalledCaplet(capletId); + if (!existing) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + // Fetch new manifest + const source = existing.manifest.registry?.source ?? 'url'; + const location = + existing.manifest.registry?.location ?? existing.manifest.bundleSpec; + const newManifest = await capletRegistryService.fetchCapletManifest( + source, + location, + newVersion, + ); + + // Validate new manifest + this.validateCaplet(newManifest); + + // Uninstall old version + await this.uninstallCaplet(capletId, terminateKernelSubcluster); + + // Install new version + const updated = await this.installCaplet( + newManifest, + {}, // Preserve existing approvals if needed + launchKernelSubcluster, + ); + + logger.log( + `Successfully updated caplet ${capletId} to version ${newVersion}`, + ); + return updated; + } + + /** + * Get all installed caplets. + * + * @returns Array of installed caplets. + */ + async getInstalledCaplets(): Promise { + return await storageService.loadInstalledCaplets(); + } + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + async getInstalledCaplet( + capletId: string, + ): Promise { + return await storageService.getInstalledCaplet(capletId); + } + + /** + * Enable or disable a caplet. + * + * @param capletId - The caplet ID to enable/disable. + * @param enabled - Whether to enable or disable the caplet. + */ + async setCapletEnabled(capletId: string, enabled: boolean): Promise { + const caplets = await storageService.loadInstalledCaplets(); + const caplet = caplets.find((c) => c.id === capletId); + + if (!caplet) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + caplet.enabled = enabled; + await storageService.saveInstalledCaplets(caplets); + + logger.log(`${enabled ? 'Enabled' : 'Disabled'} caplet: ${capletId}`); + } +} + +/** + * Singleton instance of the caplet installer service. + */ +export const capletInstallerService = new CapletInstallerService(); diff --git a/packages/omnium-gatherum/src/services/caplet-registry.ts b/packages/omnium-gatherum/src/services/caplet-registry.ts new file mode 100644 index 000000000..4c13d6733 --- /dev/null +++ b/packages/omnium-gatherum/src/services/caplet-registry.ts @@ -0,0 +1,323 @@ +import { Logger } from '@metamask/logger'; +import { is } from '@metamask/superstruct'; + +import type { + CapletManifest, + CapletSource, + CapletRegistryInfo, +} from '../types/caplet.ts'; +import { CapletManifestStruct } from '../types/caplet.ts'; + +const logger = new Logger('caplet-registry'); + +/** + * Interface for bundle fetchers that support different sources. + */ +export type BundleFetcher = { + /** + * Check if this fetcher can handle the given source. + * + * @param source - The source type to check. + * @param location - The location identifier. + * @returns True if this fetcher can handle the source. + */ + canHandle(source: CapletSource, location: string): boolean; + + /** + * Fetch a bundle from the source. + * + * @param location - The location identifier (URL, package name, CID, etc.). + * @param version - Optional version specifier. + * @returns The bundle content as a Blob. + */ + fetchBundle(location: string, version?: string): Promise; + + /** + * Fetch a manifest from the source. + * + * @param location - The location identifier. + * @param version - Optional version specifier. + * @returns The caplet manifest. + */ + fetchManifest(location: string, version?: string): Promise; +}; + +/** + * Bundle fetcher for direct URL sources. + */ +export class UrlBundleFetcher implements BundleFetcher { + canHandle(source: CapletSource, _location: string): boolean { + return source === 'url'; + } + + async fetchBundle(location: string): Promise { + logger.log(`Fetching bundle from URL: ${location}`); + const response = await fetch(location); + if (!response.ok) { + throw new Error( + `Failed to fetch bundle from ${location}: ${response.statusText}`, + ); + } + return await response.blob(); + } + + async fetchManifest(location: string): Promise { + logger.log(`Fetching manifest from URL: ${location}`); + const response = await fetch(location); + if (!response.ok) { + throw new Error( + `Failed to fetch manifest from ${location}: ${response.statusText}`, + ); + } + const manifest = await response.json(); + if (!is(manifest, CapletManifestStruct)) { + throw new Error(`Invalid manifest structure from ${location}`); + } + return manifest; + } +} + +/** + * Bundle fetcher for npm packages. + */ +export class NpmBundleFetcher implements BundleFetcher { + readonly #npmRegistryUrl: string; + + constructor(npmRegistryUrl = 'https://registry.npmjs.org') { + this.#npmRegistryUrl = npmRegistryUrl; + } + + canHandle(source: CapletSource, _location: string): boolean { + return source === 'npm'; + } + + async fetchBundle(location: string, version = 'latest'): Promise { + logger.log(`Fetching bundle from npm: ${location}@${version}`); + + // First, get package metadata + const packageUrl = `${this.#npmRegistryUrl}/${location}`; + const packageResponse = await fetch(packageUrl); + if (!packageResponse.ok) { + throw new Error( + `Failed to fetch npm package ${location}: ${packageResponse.statusText}`, + ); + } + const packageData = await packageResponse.json(); + + // Resolve version + const versionData = + version === 'latest' ? packageData['dist-tags']?.latest : version; + const versionInfo = packageData.versions?.[versionData]; + if (!versionInfo) { + throw new Error(`Version ${version} not found for package ${location}`); + } + + // Fetch the bundle from the tarball URL + const tarballUrl = versionInfo.dist?.tarball; + if (!tarballUrl) { + throw new Error(`No tarball URL found for ${location}@${version}`); + } + + const bundleResponse = await fetch(tarballUrl); + if (!bundleResponse.ok) { + throw new Error( + `Failed to fetch bundle tarball: ${bundleResponse.statusText}`, + ); + } + + return await bundleResponse.blob(); + } + + async fetchManifest( + location: string, + version = 'latest', + ): Promise { + logger.log(`Fetching manifest from npm: ${location}@${version}`); + + const packageUrl = `${this.#npmRegistryUrl}/${location}`; + const packageResponse = await fetch(packageUrl); + if (!packageResponse.ok) { + throw new Error( + `Failed to fetch npm package ${location}: ${packageResponse.statusText}`, + ); + } + const packageData = await packageResponse.json(); + + // Resolve version + const versionData = + version === 'latest' ? packageData['dist-tags']?.latest : version; + const versionInfo = packageData.versions?.[versionData]; + if (!versionInfo) { + throw new Error(`Version ${version} not found for package ${location}`); + } + + // Extract manifest from package.json + const manifest = versionInfo.capletManifest ?? versionInfo; + if (!is(manifest, CapletManifestStruct)) { + throw new Error( + `Invalid caplet manifest in npm package ${location}@${version}`, + ); + } + + return manifest; + } +} + +/** + * Caplet registry service for discovering and fetching caplets. + */ +export class CapletRegistryService { + readonly #fetchers: BundleFetcher[]; + + readonly #registries: string[]; + + constructor() { + this.#fetchers = [new UrlBundleFetcher(), new NpmBundleFetcher()]; + this.#registries = []; + } + + /** + * Add a registry URL for discovering caplets. + * + * @param url - The registry URL to add. + */ + addRegistry(url: string): void { + if (!this.#registries.includes(url)) { + this.#registries.push(url); + logger.log(`Added registry: ${url}`); + } + } + + /** + * Remove a registry URL. + * + * @param url - The registry URL to remove. + */ + removeRegistry(url: string): void { + const index = this.#registries.indexOf(url); + if (index !== -1) { + this.#registries.splice(index, 1); + logger.log(`Removed registry: ${url}`); + } + } + + /** + * Get all registered registry URLs. + * + * @returns Array of registry URLs. + */ + getRegistries(): string[] { + return [...this.#registries]; + } + + /** + * Discover caplets from registries. + * + * @param registryUrl - Optional specific registry URL to query. + * @returns Array of discovered caplet manifests. + */ + async discoverCaplets(registryUrl?: string): Promise { + const registries = registryUrl ? [registryUrl] : this.#registries; + + if (registries.length === 0) { + logger.warn('No registries configured'); + return []; + } + + const manifests: CapletManifest[] = []; + + for (const url of registries) { + try { + logger.log(`Discovering caplets from registry: ${url}`); + const response = await fetch(url); + if (!response.ok) { + logger.error( + `Failed to fetch registry ${url}: ${response.statusText}`, + ); + continue; + } + const data = await response.json(); + const caplets = Array.isArray(data) ? data : (data.caplets ?? []); + + for (const caplet of caplets) { + if (is(caplet, CapletManifestStruct)) { + manifests.push(caplet); + } else { + logger.warn(`Invalid caplet manifest in registry ${url}`); + } + } + } catch (error) { + logger.error(`Error discovering caplets from ${url}`, error); + } + } + + return manifests; + } + + /** + * Fetch a caplet manifest from a source. + * + * @param source - The source type (url, npm). + * @param location - The location identifier. + * @param version - Optional version specifier. + * @returns The caplet manifest. + */ + async fetchCapletManifest( + source: CapletSource, + location: string, + version?: string, + ): Promise { + const fetcher = this.#fetchers.find((f) => f.canHandle(source, location)); + if (!fetcher) { + throw new Error(`No fetcher available for source: ${source}`); + } + + return await fetcher.fetchManifest(location, version); + } + + /** + * Fetch a caplet bundle from a source. + * + * @param bundleSpec - The bundle specification (URL, npm package, etc.). + * @param source - Optional source type. If not provided, will be inferred. + * @param version - Optional version specifier. + * @returns The bundle content as a Blob. + */ + async fetchCapletBundle( + bundleSpec: string, + source?: CapletSource, + version?: string, + ): Promise { + // Infer source if not provided + let inferredSource: CapletSource = source ?? 'url'; + if (!source) { + if (bundleSpec.includes('npmjs.org') || !bundleSpec.includes('://')) { + inferredSource = 'npm'; + } else { + inferredSource = 'url'; + } + } + + // Extract location from bundleSpec + let location = bundleSpec; + if (inferredSource === 'npm' && bundleSpec.includes('@')) { + const parts = bundleSpec.split('@'); + location = parts[0] ?? bundleSpec; + version = version ?? parts[1]; + } + + const fetcher = this.#fetchers.find((f) => + f.canHandle(inferredSource, location), + ); + if (!fetcher) { + throw new Error(`No fetcher available for source: ${inferredSource}`); + } + + return await fetcher.fetchBundle(location, version); + } +} + +/** + * Singleton instance of the caplet registry service. + */ +export const capletRegistryService = new CapletRegistryService(); diff --git a/packages/omnium-gatherum/src/services/host-service.ts b/packages/omnium-gatherum/src/services/host-service.ts new file mode 100644 index 000000000..87a3c4a71 --- /dev/null +++ b/packages/omnium-gatherum/src/services/host-service.ts @@ -0,0 +1,162 @@ +import type { CapData } from '@endo/marshal'; +import { rpcMethodSpecs } from '@metamask/kernel-browser-runtime'; +import { RpcClient } from '@metamask/kernel-rpc-methods'; +import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import type { ClusterConfig, KRef } from '@metamask/ocap-kernel'; +import type { Json, JsonRpcResponse } from '@metamask/utils'; + +import { capletBootstrapService } from './caplet-bootstrap.ts'; +import { capletInstallerService } from './caplet-installer.ts'; +import { storageService } from './storage.ts'; + +const logger = new Logger('host-service'); + +/** + * Host service that provides caplet management APIs and kernel access. + */ +export class HostService { + #rpcClient: RpcClient | null = null; + + /** + * Initialize the host service with kernel RPC client. + * + * @param writeRequest - Function to write RPC requests. + * @param handleResponse - Function to handle RPC responses. + */ + initialize( + writeRequest: (request: JsonRpcCall) => Promise, + handleResponse: (id: string, response: JsonRpcResponse) => void, + ): void { + this.#rpcClient = new RpcClient( + rpcMethodSpecs, + writeRequest, + 'host-service:', + ); + + // Set up response handling + // Note: The caller should call handleResponse when responses arrive + logger.log('Host service initialized'); + } + + /** + * Launch a subcluster via kernel RPC. + * + * @param config - The cluster configuration. + * @returns The bootstrap result. + */ + async launchKernelSubcluster( + config: ClusterConfig, + ): Promise | null> { + if (!this.#rpcClient) { + throw new Error('Host service not initialized'); + } + + const result = await this.#rpcClient.call('launchSubcluster', { + config, + }); + + return result as CapData | null; + } + + /** + * Terminate a subcluster via kernel RPC. + * + * @param subclusterId - The subcluster ID to terminate. + */ + async terminateKernelSubcluster(subclusterId: string): Promise { + if (!this.#rpcClient) { + throw new Error('Host service not initialized'); + } + + await this.#rpcClient.call('terminateSubcluster', { + id: subclusterId, + }); + } + + /** + * Queue a message to a vat object via kernel RPC. + * + * @param target - The target KRef. + * @param method - The method name. + * @param args - The method arguments. + * @returns The message result. + */ + async queueMessage( + target: KRef, + method: string, + args: Json[], + ): Promise { + if (!this.#rpcClient) { + throw new Error('Host service not initialized'); + } + + return await this.#rpcClient.call('queueMessage', [target, method, args]); + } + + /** + * Install a caplet with kernel integration. + * + * @param manifest - The caplet manifest. + * @returns The installed caplet. + */ + async installCaplet( + manifest: Parameters[0], + ) { + return await capletInstallerService.installCaplet( + manifest, + {}, + async (config) => { + const result = await this.launchKernelSubcluster(config); + // Get subcluster ID from kernel status if needed + // For now, we'll track it separately + return result; + }, + ); + } + + /** + * Uninstall a caplet with kernel integration. + * + * @param capletId - The caplet ID to uninstall. + */ + async uninstallCaplet(capletId: string): Promise { + const caplet = await storageService.getInstalledCaplet(capletId); + await capletInstallerService.uninstallCaplet( + capletId, + caplet?.subclusterId + ? async (subclusterId) => { + await this.terminateKernelSubcluster(subclusterId); + } + : undefined, + ); + } + + /** + * Bootstrap a caplet (launch its subcluster). + * + * @param capletId - The caplet ID to bootstrap. + */ + async bootstrapCaplet(capletId: string): Promise { + const caplet = await storageService.getInstalledCaplet(capletId); + if (!caplet) { + throw new Error(`Caplet ${capletId} is not installed`); + } + + const result = await capletBootstrapService.bootstrapCaplet( + capletId, + caplet.manifest.clusterConfig, + async (config) => this.launchKernelSubcluster(config), + ); + + // Store subcluster ID if we can extract it + // Note: We'd need to query kernel status to get the actual subcluster ID + // For now, this is a placeholder + logger.log(`Bootstrapped caplet ${capletId}, result:`, result); + } +} + +/** + * Singleton instance of the host service. + */ +export const hostService = new HostService(); diff --git a/packages/omnium-gatherum/src/services/storage.test.ts b/packages/omnium-gatherum/src/services/storage.test.ts new file mode 100644 index 000000000..1f44de695 --- /dev/null +++ b/packages/omnium-gatherum/src/services/storage.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { StorageService } from './storage.ts'; +import type { InstalledCaplet, CapabilityGrant } from '../types/caplet.ts'; + +// Mock chrome.storage +const mockStorage = { + local: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }, +}; + +vi.stubGlobal('chrome', { + storage: mockStorage, +}); + +describe('StorageService', () => { + let storageService: StorageService; + + beforeEach(() => { + storageService = new StorageService(); + vi.clearAllMocks(); + }); + + describe('saveInstalledCaplets', () => { + it('saves installed caplets to storage', async () => { + const caplets: InstalledCaplet[] = [ + { + id: 'test@1.0.0', + manifest: { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }, + installedAt: new Date().toISOString(), + enabled: true, + }, + ]; + + mockStorage.local.set.mockResolvedValue(undefined); + + await storageService.saveInstalledCaplets(caplets); + + expect(mockStorage.local.set).toHaveBeenCalledWith({ + installedCaplets: caplets, + }); + }); + }); + + describe('loadInstalledCaplets', () => { + it('loads installed caplets from storage', async () => { + const caplets: InstalledCaplet[] = [ + { + id: 'test@1.0.0', + manifest: { + name: 'test', + version: '1.0.0', + bundleSpec: 'http://example.com/bundle', + clusterConfig: { + bootstrap: 'test', + vats: { test: {} }, + }, + }, + installedAt: new Date().toISOString(), + enabled: true, + }, + ]; + + mockStorage.local.get.mockResolvedValue({ + installedCaplets: caplets, + }); + + const result = await storageService.loadInstalledCaplets(); + + expect(result).toStrictEqual(caplets); + expect(mockStorage.local.get).toHaveBeenCalledWith('installedCaplets'); + }); + + it('returns empty array if no caplets stored', async () => { + mockStorage.local.get.mockResolvedValue({}); + + const result = await storageService.loadInstalledCaplets(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('saveCapabilityGrants', () => { + it('saves capability grants to storage', async () => { + const grants: CapabilityGrant[] = [ + { + capletId: 'test@1.0.0', + capabilityName: 'test-capability', + target: 'ko1', + grantedAt: new Date().toISOString(), + }, + ]; + + mockStorage.local.set.mockResolvedValue(undefined); + + await storageService.saveCapabilityGrants(grants); + + expect(mockStorage.local.set).toHaveBeenCalledWith({ + capabilityGrants: grants, + }); + }); + }); + + describe('loadCapabilityGrants', () => { + it('loads capability grants from storage', async () => { + const grants: CapabilityGrant[] = [ + { + capletId: 'test@1.0.0', + capabilityName: 'test-capability', + target: 'ko1', + grantedAt: new Date().toISOString(), + }, + ]; + + mockStorage.local.get.mockResolvedValue({ + capabilityGrants: grants, + }); + + const result = await storageService.loadCapabilityGrants(); + + expect(result).toStrictEqual(grants); + }); + }); + + describe('getCapabilityGrantsForCaplet', () => { + it('filters grants by caplet ID', async () => { + const grants: CapabilityGrant[] = [ + { + capletId: 'test@1.0.0', + capabilityName: 'cap1', + target: 'ko1', + grantedAt: new Date().toISOString(), + }, + { + capletId: 'other@1.0.0', + capabilityName: 'cap2', + target: 'ko2', + grantedAt: new Date().toISOString(), + }, + ]; + + mockStorage.local.get.mockResolvedValue({ + capabilityGrants: grants, + }); + + const result = + await storageService.getCapabilityGrantsForCaplet('test@1.0.0'); + + expect(result).toHaveLength(1); + expect(result[0]?.capletId).toBe('test@1.0.0'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/services/storage.ts b/packages/omnium-gatherum/src/services/storage.ts new file mode 100644 index 000000000..239282165 --- /dev/null +++ b/packages/omnium-gatherum/src/services/storage.ts @@ -0,0 +1,134 @@ +import { Logger } from '@metamask/logger'; + +import type { InstalledCaplet, CapabilityGrant } from '../types/caplet.ts'; + +const logger = new Logger('storage'); + +const STORAGE_KEYS = { + INSTALLED_CAPLETS: 'installedCaplets', + CAPABILITY_GRANTS: 'capabilityGrants', +} as const; + +/** + * Storage service for persisting caplet and capability data. + */ +export class StorageService { + /** + * Save installed caplets to storage. + * + * @param caplets - Array of installed caplets to save. + */ + async saveInstalledCaplets(caplets: InstalledCaplet[]): Promise { + try { + await chrome.storage.local.set({ + [STORAGE_KEYS.INSTALLED_CAPLETS]: caplets, + }); + logger.log(`Saved ${caplets.length} installed caplets`); + } catch (error) { + logger.error('Failed to save installed caplets', error); + throw error; + } + } + + /** + * Load installed caplets from storage. + * + * @returns Array of installed caplets. + */ + async loadInstalledCaplets(): Promise { + try { + const result = await chrome.storage.local.get( + STORAGE_KEYS.INSTALLED_CAPLETS, + ); + const caplets = result[STORAGE_KEYS.INSTALLED_CAPLETS] ?? []; + logger.log(`Loaded ${caplets.length} installed caplets`); + return caplets as InstalledCaplet[]; + } catch (error) { + logger.error('Failed to load installed caplets', error); + return []; + } + } + + /** + * Get a specific installed caplet by ID. + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + async getInstalledCaplet( + capletId: string, + ): Promise { + const caplets = await this.loadInstalledCaplets(); + return caplets.find((caplet) => caplet.id === capletId); + } + + /** + * Save capability grants to storage. + * + * @param grants - Array of capability grants to save. + */ + async saveCapabilityGrants(grants: CapabilityGrant[]): Promise { + try { + await chrome.storage.local.set({ + [STORAGE_KEYS.CAPABILITY_GRANTS]: grants, + }); + logger.log(`Saved ${grants.length} capability grants`); + } catch (error) { + logger.error('Failed to save capability grants', error); + throw error; + } + } + + /** + * Load capability grants from storage. + * + * @returns Array of capability grants. + */ + async loadCapabilityGrants(): Promise { + try { + const result = await chrome.storage.local.get( + STORAGE_KEYS.CAPABILITY_GRANTS, + ); + const grants = result[STORAGE_KEYS.CAPABILITY_GRANTS] ?? []; + logger.log(`Loaded ${grants.length} capability grants`); + return grants as CapabilityGrant[]; + } catch (error) { + logger.error('Failed to load capability grants', error); + return []; + } + } + + /** + * Get capability grants for a specific caplet. + * + * @param capletId - The caplet ID to get grants for. + * @returns Array of capability grants for the caplet. + */ + async getCapabilityGrantsForCaplet( + capletId: string, + ): Promise { + const grants = await this.loadCapabilityGrants(); + return grants.filter((grant) => grant.capletId === capletId); + } + + /** + * Clear all stored data. + */ + async clearAll(): Promise { + try { + await chrome.storage.local.remove([ + STORAGE_KEYS.INSTALLED_CAPLETS, + STORAGE_KEYS.CAPABILITY_GRANTS, + ]); + logger.log('Cleared all storage'); + } catch (error) { + logger.error('Failed to clear storage', error); + throw error; + } + } +} + +/** + * Singleton instance of the storage service. + */ +export const storageService = new StorageService(); diff --git a/packages/omnium-gatherum/src/services/ui-renderer.ts b/packages/omnium-gatherum/src/services/ui-renderer.ts new file mode 100644 index 000000000..e4f558bbc --- /dev/null +++ b/packages/omnium-gatherum/src/services/ui-renderer.ts @@ -0,0 +1,186 @@ +import { Logger } from '@metamask/logger'; +import { initializeMessageChannel } from '@metamask/streams/browser'; + +import type { InstalledCaplet } from '../types/caplet.ts'; + +const logger = new Logger('ui-renderer'); + +/** + * UI mount point types. + */ +export type UIMountPoint = 'popup' | 'sidebar' | 'modal' | 'custom'; + +/** + * UI renderer service for securely rendering caplet UIs in isolated iframes. + */ +export class UIRendererService { + readonly #renderedUIs: Map = new Map(); + + /** + * Render a caplet's UI in an isolated iframe. + * + * @param capletId - The caplet ID to render. + * @param caplet - The installed caplet metadata. + * @param mountPoint - Where to mount the UI. + * @param container - The container element to mount the iframe in. + * @returns The created iframe element. + */ + async renderCapletUI( + capletId: string, + caplet: InstalledCaplet, + mountPoint: UIMountPoint, + container: HTMLElement, + ): Promise { + logger.log( + `Rendering UI for caplet: ${capletId} at mount point: ${mountPoint}`, + ); + + // Check if already rendered + const existing = this.#renderedUIs.get(capletId); + if (existing) { + logger.log(`UI for caplet ${capletId} already rendered`); + return existing; + } + + // Get UI configuration from manifest + const uiConfig = caplet.manifest.ui; + if (!uiConfig) { + throw new Error(`Caplet ${capletId} does not have UI configuration`); + } + + // Create iframe + const iframe = document.createElement('iframe'); + const iframeId = `caplet-ui-${capletId}`; + iframe.id = iframeId; + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin'); + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + // Set iframe source to caplet UI iframe template + // The UI bundle will be loaded within the iframe + const uiBundleUrl = caplet.manifest.bundleSpec; // For now, use bundleSpec + const iframeUrl = new URL('caplet-ui-iframe.html', window.location.href); + iframeUrl.searchParams.set('capletId', capletId); + iframeUrl.searchParams.set('uiBundle', uiBundleUrl); + iframeUrl.searchParams.set('entryPoint', uiConfig.entryPoint); + + iframe.src = iframeUrl.toString(); + + // Append to container + container.appendChild(iframe); + + // Wait for iframe to load + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + `Timeout waiting for caplet UI iframe to load: ${capletId}`, + ), + ); + }, 10000); + + iframe.onload = () => { + clearTimeout(timeout); + resolve(); + }; + + iframe.onerror = (error) => { + clearTimeout(timeout); + reject(error); + }; + }); + + // Set up message channel for communication + const port = await initializeMessageChannel((message, transfer) => + iframe.contentWindow?.postMessage(message, '*', transfer), + ); + + // Store iframe reference + this.#renderedUIs.set(capletId, iframe); + + logger.log(`Successfully rendered UI for caplet: ${capletId}`); + return iframe; + } + + /** + * Unmount a caplet's UI. + * + * @param capletId - The caplet ID to unmount. + */ + unmountCapletUI(capletId: string): void { + logger.log(`Unmounting UI for caplet: ${capletId}`); + + const iframe = this.#renderedUIs.get(capletId); + if (iframe) { + iframe.remove(); + this.#renderedUIs.delete(capletId); + logger.log(`Successfully unmounted UI for caplet: ${capletId}`); + } else { + logger.warn(`No UI found for caplet: ${capletId}`); + } + } + + /** + * Check if a caplet's UI is currently rendered. + * + * @param capletId - The caplet ID to check. + * @returns True if the UI is rendered. + */ + isUIRendered(capletId: string): boolean { + return this.#renderedUIs.has(capletId); + } + + /** + * Get the iframe element for a rendered caplet UI. + * + * @param capletId - The caplet ID. + * @returns The iframe element or undefined if not rendered. + */ + getCapletUIIframe(capletId: string): HTMLIFrameElement | undefined { + return this.#renderedUIs.get(capletId); + } + + /** + * Create a capability for UI rendering. + * This allows caplets to request UI rendering capabilities. + * + * @param capletId - The caplet ID. + * @param caplet - The installed caplet metadata. + * @returns A UI capability object. + */ + createUICapability( + capletId: string, + caplet: InstalledCaplet, + ): { + render: ( + mountPoint: UIMountPoint, + container: HTMLElement, + ) => Promise; + unmount: () => void; + } { + return { + render: async (mountPoint: UIMountPoint, container: HTMLElement) => { + return this.renderCapletUI(capletId, caplet, mountPoint, container); + }, + unmount: () => { + this.unmountCapletUI(capletId); + }, + }; + } + + /** + * Unmount all rendered caplet UIs. + */ + unmountAll(): void { + logger.log('Unmounting all caplet UIs'); + for (const capletId of this.#renderedUIs.keys()) { + this.unmountCapletUI(capletId); + } + } +} + +/** + * Singleton instance of the UI renderer service. + */ +export const uiRendererService = new UIRendererService(); diff --git a/packages/omnium-gatherum/src/types/caplet.ts b/packages/omnium-gatherum/src/types/caplet.ts new file mode 100644 index 000000000..f5facb0c1 --- /dev/null +++ b/packages/omnium-gatherum/src/types/caplet.ts @@ -0,0 +1,126 @@ +import { VatConfigStruct } from '@metamask/ocap-kernel'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; +import { + array, + boolean, + exactOptional, + object, + record, + string, + type, + union, + literal, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +/** + * Source type for caplet bundle fetching. + */ +export type CapletSource = 'url' | 'npm'; + +/** + * Capability request from a caplet. + */ +export const CapabilityRequestStruct = object({ + name: string(), + description: string(), + required: exactOptional(boolean()), +}); + +export type CapabilityRequest = Infer; + +/** + * Capability definition provided by a caplet. + */ +export const CapabilityDefinitionStruct = object({ + name: string(), + description: string(), + interface: string(), // Interface name or description +}); + +export type CapabilityDefinition = Infer; + +/** + * UI configuration for a caplet. + */ +export const CapletUIConfigStruct = object({ + entryPoint: string(), // Path to UI component within bundle + mountPoint: exactOptional( + union([ + literal('popup'), + literal('sidebar'), + literal('modal'), + literal('custom'), + ]), + ), +}); + +export type CapletUIConfig = Infer; + +/** + * Registry information for a caplet. + */ +export const CapletRegistryInfoStruct = object({ + source: union([literal('url'), literal('npm')]), + location: string(), // URL or npm package name +}); + +export type CapletRegistryInfo = Infer; + +/** + * Caplet manifest structure. + */ +export const CapletManifestStruct = object({ + name: string(), + version: string(), + description: exactOptional(string()), + author: exactOptional(string()), + bundleSpec: string(), // URL or path to vat bundle(s) + clusterConfig: object({ + bootstrap: string(), + forceReset: exactOptional(boolean()), + services: exactOptional(array(string())), + vats: record(string(), VatConfigStruct), + }), + ui: exactOptional(CapletUIConfigStruct), + capabilities: exactOptional( + object({ + requested: array(CapabilityRequestStruct), + provided: exactOptional(array(CapabilityDefinitionStruct)), + }), + ), + registry: exactOptional(CapletRegistryInfoStruct), +}); + +export type CapletManifest = Infer; + +/** + * Installed caplet metadata stored in extension storage. + */ +export const InstalledCapletStruct = object({ + id: string(), // Unique identifier: `${name}@${version}` + manifest: CapletManifestStruct, + subclusterId: exactOptional(string()), // Subcluster ID if launched + installedAt: string(), // ISO timestamp + enabled: exactOptional(boolean()), +}); + +export type InstalledCaplet = Infer; + +/** + * Capability grant stored in extension storage. + */ +export const CapabilityGrantStruct = object({ + capletId: string(), + capabilityName: string(), + target: string(), // KRef or service name + grantedAt: string(), // ISO timestamp + restrictions: exactOptional( + object({ + expiresAt: exactOptional(string()), // ISO timestamp + scope: exactOptional(string()), + }), + ), +}); + +export type CapabilityGrant = Infer; diff --git a/packages/omnium-gatherum/src/ui/App.tsx b/packages/omnium-gatherum/src/ui/App.tsx index 9200f89d2..e0e6161dd 100644 --- a/packages/omnium-gatherum/src/ui/App.tsx +++ b/packages/omnium-gatherum/src/ui/App.tsx @@ -1,3 +1,5 @@ +import { HostShell } from './HostShell.tsx'; + export const App: React.FC = () => { - return
Omnium Gatherum
; + return ; }; diff --git a/packages/omnium-gatherum/src/ui/CapabilityManager.tsx b/packages/omnium-gatherum/src/ui/CapabilityManager.tsx new file mode 100644 index 000000000..7c3867dcd --- /dev/null +++ b/packages/omnium-gatherum/src/ui/CapabilityManager.tsx @@ -0,0 +1,181 @@ +import { + Box, + Text as TextComponent, + TextVariant, + TextColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { capabilityManagerService } from '../services/capability-manager.ts'; +import { capletInstallerService } from '../services/caplet-installer.ts'; +import type { CapabilityGrant, InstalledCaplet } from '../types/caplet.ts'; + +/** + * Component for viewing and managing capability grants. + */ +export const CapabilityManager: React.FC = () => { + const [caplets, setCaplets] = useState([]); + const [grants, setGrants] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedCapletId, setSelectedCapletId] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [installedCaplets, allGrants] = await Promise.all([ + capletInstallerService.getInstalledCaplets(), + capabilityManagerService.getAllGrants(), + ]); + setCaplets(installedCaplets); + setGrants(allGrants); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleRevoke = useCallback( + async (capletId: string, capabilityName: string) => { + if ( + !confirm( + `Are you sure you want to revoke capability "${capabilityName}" from ${capletId}?`, + ) + ) { + return; + } + try { + await capabilityManagerService.revokeCapability( + capletId, + capabilityName, + ); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [loadData], + ); + + const filteredGrants = selectedCapletId + ? grants.filter((grant) => grant.capletId === selectedCapletId) + : grants; + + return ( + + + Capability Manager + + + {error && ( + + + Error: {error} + + + )} + +
+ + Filter by caplet: + + +
+ + {loading && grants.length === 0 && ( + + Loading capabilities... + + )} + + {!loading && filteredGrants.length === 0 && ( + + No capabilities granted + {selectedCapletId ? ' for this caplet' : ''}. + + )} + +
+ {filteredGrants.map((grant, index) => ( + +
+
+ + {grant.capabilityName} + + + Caplet: {grant.capletId} + + + Target: {grant.target} + + {grant.restrictions?.expiresAt && ( + + Expires:{' '} + {new Date(grant.restrictions.expiresAt).toLocaleString()} + + )} +
+ +
+
+ ))} +
+
+ ); +}; diff --git a/packages/omnium-gatherum/src/ui/CapletStore.tsx b/packages/omnium-gatherum/src/ui/CapletStore.tsx new file mode 100644 index 000000000..7393118c8 --- /dev/null +++ b/packages/omnium-gatherum/src/ui/CapletStore.tsx @@ -0,0 +1,141 @@ +import { + Box, + Text as TextComponent, + TextVariant, + TextColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { capletInstallerService } from '../services/caplet-installer.ts'; +import { capletRegistryService } from '../services/caplet-registry.ts'; +import type { CapletManifest } from '../types/caplet.ts'; + +/** + * Component for browsing and installing caplets from registries. + */ +export const CapletStore: React.FC = () => { + const [caplets, setCaplets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadCaplets = useCallback(async () => { + setLoading(true); + setError(null); + try { + const discovered = await capletRegistryService.discoverCaplets(); + setCaplets(discovered); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadCaplets(); + }, [loadCaplets]); + + const handleInstall = useCallback( + async (manifest: CapletManifest) => { + try { + await capletInstallerService.installCaplet(manifest); + // Refresh the list + await loadCaplets(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [loadCaplets], + ); + + return ( + + + Caplet Store + + + + + {error && ( + + + Error: {error} + + + )} + + {loading && caplets.length === 0 && ( + + Loading caplets... + + )} + + {!loading && caplets.length === 0 && !error && ( + + No caplets found. Add a registry to discover caplets. + + )} + +
+ {caplets.map((caplet) => ( + +
+
+ + {caplet.name} + + + v{caplet.version} + +
+ +
+ {caplet.description && ( + + {caplet.description} + + )} +
+ ))} +
+
+ ); +}; diff --git a/packages/omnium-gatherum/src/ui/HostShell.tsx b/packages/omnium-gatherum/src/ui/HostShell.tsx new file mode 100644 index 000000000..55dd40cf5 --- /dev/null +++ b/packages/omnium-gatherum/src/ui/HostShell.tsx @@ -0,0 +1,91 @@ +import { + Box, + Text as TextComponent, + TextVariant, + TextColor, +} from '@metamask/design-system-react'; +import { useState } from 'react'; + +import { CapabilityManager } from './CapabilityManager.tsx'; +import { CapletStore } from './CapletStore.tsx'; +import { InstalledCaplets } from './InstalledCaplets.tsx'; + +type Tab = 'store' | 'installed' | 'capabilities'; + +/** + * Main shell component that orchestrates UI placement and manages caplets. + */ +export const HostShell: React.FC = () => { + const [activeTab, setActiveTab] = useState('installed'); + const [showCapletUI, setShowCapletUI] = useState(false); + + return ( + + {/* Header */} + + + Omnium Gatherum + + + Caplet Host Application + + + + {/* Tabs */} + + + + + + + {/* Content */} + + {activeTab === 'store' && } + {activeTab === 'installed' && } + {activeTab === 'capabilities' && } + + + {/* Caplet UI Container */} + {showCapletUI && ( + + )} + + ); +}; diff --git a/packages/omnium-gatherum/src/ui/InstalledCaplets.tsx b/packages/omnium-gatherum/src/ui/InstalledCaplets.tsx new file mode 100644 index 000000000..805916408 --- /dev/null +++ b/packages/omnium-gatherum/src/ui/InstalledCaplets.tsx @@ -0,0 +1,183 @@ +import { + Box, + Text as TextComponent, + TextVariant, + TextColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { capletInstallerService } from '../services/caplet-installer.ts'; +import { uiRendererService } from '../services/ui-renderer.ts'; +import type { InstalledCaplet } from '../types/caplet.ts'; + +/** + * Component for listing and managing installed caplets. + */ +export const InstalledCaplets: React.FC = () => { + const [caplets, setCaplets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadCaplets = useCallback(async () => { + setLoading(true); + setError(null); + try { + const installed = await capletInstallerService.getInstalledCaplets(); + setCaplets(installed); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadCaplets(); + }, [loadCaplets]); + + const handleUninstall = useCallback( + async (capletId: string) => { + if (!confirm(`Are you sure you want to uninstall ${capletId}?`)) { + return; + } + try { + await capletInstallerService.uninstallCaplet(capletId); + await loadCaplets(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [loadCaplets], + ); + + const handleToggleEnabled = useCallback( + async (capletId: string, enabled: boolean) => { + try { + await capletInstallerService.setCapletEnabled(capletId, !enabled); + await loadCaplets(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [loadCaplets], + ); + + const handleRenderUI = useCallback(async (caplet: InstalledCaplet) => { + try { + const container = document.getElementById('caplet-ui-container'); + if (!container) { + throw new Error('UI container not found'); + } + await uiRendererService.renderCapletUI( + caplet.id, + caplet, + caplet.manifest.ui?.mountPoint ?? 'popup', + container, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + return ( + + + Installed Caplets + + + {error && ( + + + Error: {error} + + + )} + + {loading && caplets.length === 0 && ( + + Loading caplets... + + )} + + {!loading && caplets.length === 0 && ( + + No caplets installed. Install caplets from the Caplet Store. + + )} + +
+ {caplets.map((caplet) => ( + +
+
+ + {caplet.manifest.name} + + + v{caplet.manifest.version} + {caplet.enabled === false && ' (disabled)'} + +
+
+ {caplet.manifest.ui && ( + + )} + + +
+
+ {caplet.manifest.description && ( + + {caplet.manifest.description} + + )} +
+ ))} +
+
+ ); +}; diff --git a/packages/omnium-gatherum/src/ui/caplet-ui-iframe.html b/packages/omnium-gatherum/src/ui/caplet-ui-iframe.html new file mode 100644 index 000000000..1ec53bd15 --- /dev/null +++ b/packages/omnium-gatherum/src/ui/caplet-ui-iframe.html @@ -0,0 +1,83 @@ + + + + + Caplet UI + + + +
+ + + + diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 2313eae62..5f754da9e 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -79,6 +79,10 @@ export default defineConfig(({ mode }) => { 'kernel-panel': path.resolve(sourceDir, 'devtools/kernel-panel.html'), offscreen: path.resolve(sourceDir, 'offscreen.html'), popup: path.resolve(sourceDir, 'popup.html'), + 'caplet-ui-iframe': path.resolve( + sourceDir, + 'ui/caplet-ui-iframe.html', + ), // kernel-browser-runtime 'kernel-worker': path.resolve( kernelBrowserRuntimeSrcDir, diff --git a/yarn.lock b/yarn.lock index 548e6d375..8035930b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3627,6 +3627,7 @@ __metadata: eslint-plugin-promise: "npm:^7.2.1" jsdom: "npm:^26.0.0" playwright: "npm:^1.54.2" + preact: "npm:^10.27.2" prettier: "npm:^3.5.3" react: "npm:^17.0.2" react-dom: "npm:^17.0.2" @@ -12210,6 +12211,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.27.2": + version: 10.27.2 + resolution: "preact@npm:10.27.2" + checksum: 10/e568fb968579e73921119232fcdfa6a5b6a57632742b905ec5127b8ef77abee3a8040d8342022af7845e3b43e97ca06faafbf734aa234dd95c0d62474cd0d03f + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" From d51ac7e151f893352e9e61d5b9942c7eee65b13a Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:38:40 -0800 Subject: [PATCH 2/2] chore: Commit cursor plan --- ...-application-architecture-8fa158ab.plan.md | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 .cursor/plans/omnium-host-application-architecture-8fa158ab.plan.md diff --git a/.cursor/plans/omnium-host-application-architecture-8fa158ab.plan.md b/.cursor/plans/omnium-host-application-architecture-8fa158ab.plan.md new file mode 100644 index 000000000..9dca172bf --- /dev/null +++ b/.cursor/plans/omnium-host-application-architecture-8fa158ab.plan.md @@ -0,0 +1,307 @@ +--- +name: Omnium Host Application Architecture Plan +overview: '' +todos: + - id: 71afd73f-1bfb-4180-be77-f3fa2ac06f43 + content: Define caplet metadata schema with types for manifest, capabilities, and UI configuration + status: pending + - id: c2b17ac1-4971-4023-82bc-efac44366f29 + content: Implement storage service for persisting installed caplets and capability grants + status: pending + - id: 3a0c0bdc-4c03-4fce-8859-cb0cedbdbda1 + content: Implement caplet registry service for discovering and fetching caplets from npm/IPFS + status: pending + - id: 702cfa9f-5ec6-436e-b31c-657e489cc097 + content: Implement caplet installer service for installing, updating, and uninstalling caplets + status: pending + - id: c0246baf-dd4d-48ba-9751-583584386dac + content: Implement capability manager for tracking and managing capability grants between caplets + status: pending + - id: 2ad5bf1b-93a6-43a7-87e5-cf39904c1e7a + content: Implement caplet bootstrap service for launching caplets as subclusters with capability injection + status: pending + - id: a5c46f7c-78f7-4743-9482-ec7d37a20120 + content: Implement UI renderer service for securely rendering caplet UIs in isolated iframes + status: pending + - id: 90fae048-f547-425c-86c5-6171cbd0953c + content: Create host shell UI components (CapletStore, InstalledCaplets, CapabilityManager, HostShell) + status: pending + - id: e99d2afb-fc80-4939-98ac-b1a9e5989f72 + content: Integrate all services in background script and connect to host shell UI + status: pending + - id: e09c5982-98d1-49ec-b693-8ba8ef56e408 + content: Add unit tests for services and E2E tests for caplet installation and UI rendering flows + status: pending +--- + +# Omnium Host Application Architecture Plan + +## Overview + +The host application orchestrates "caplets" (collections of mutually suspicious vats/subclusters) published as npm packages. Caplets communicate via CapTP and need to render secure, isolated UIs. This plan outlines the components needed to build the host application on top of the existing ocap kernel infrastructure. + +## Architecture Principles + +- **Caplets as Subclusters**: Each caplet is launched as a subcluster containing one or more vats +- **Capability-Based Security**: Caplets request explicit capabilities; users approve/revoke them +- **Isolated UI Rendering**: Caplet UIs render in isolated contexts (iframes or shadow DOM) to prevent mutual interference +- **Host Orchestration**: The host manages caplet lifecycle, UI placement, and capability grants + +## Components to Build + +### 1. Caplet Metadata Schema (`packages/omnium-gatherum/src/types/caplet.ts`) + +Define the structure for caplet packages: + +```typescript +type CapletManifest = { + name: string; + version: string; + description?: string; + author?: string; + bundleSpec: string; // URL or path to vat bundle(s) + clusterConfig: ClusterConfig; // Subcluster configuration + ui?: { + entryPoint: string; // Path to UI component within bundle + mountPoint?: string; // Where to render (e.g., 'popup', 'sidebar', 'modal') + }; + capabilities?: { + requested: CapabilityRequest[]; + provided?: CapabilityDefinition[]; + }; + registry?: { + source: 'npm' | 'ipfs' | 'url'; + location: string; + }; +}; +``` + +### 2. Caplet Registry Service (`packages/omnium-gatherum/src/services/caplet-registry.ts`) + +Service for discovering and fetching caplets from registries: + +- **Methods**: + + - `discoverCaplets(registryUrl?: string)`: Query registry for available caplets + - `fetchCapletManifest(source, location)`: Fetch caplet metadata from a source (url, npm, ipfs) + - `fetchCapletBundle(bundleSpec)`: Download caplet bundle(s) from any supported source + - `addRegistry(url)`: Add a new registry source + - `removeRegistry(url)`: Remove a registry source + +- **Implementation Notes**: + - Use a plugin/strategy pattern for extensible source support + - Implement `UrlBundleFetcher`, `NpmBundleFetcher`, `IpfsBundleFetcher` (future) + - Support direct URL fetching for caplet bundles (e.g., `https://example.com/caplet.bundle`) + - Support npm packages (fetch from npm registry) + - Support IPFS CIDs for decentralized distribution (future) + - Validate manifest structure using `@metamask/superstruct` + - Cache fetched manifests and bundles + +### 3. Caplet Installer (`packages/omnium-gatherum/src/services/caplet-installer.ts`) + +Handles caplet installation, validation, and configuration: + +- **Methods**: + + - `installCaplet(manifest, userApprovals?)`: Install a caplet with user capability approvals + - `uninstallCaplet(capletId)`: Remove a caplet and its subcluster + - `updateCaplet(capletId, newVersion)`: Update to a new version + - `validateCaplet(manifest)`: Validate manifest structure and bundle accessibility + +- **Implementation Notes**: + - Store installed caplets in extension storage (chrome.storage.local) + - Create subcluster configuration from caplet manifest + - Launch subcluster via kernel API after installation + - Track installed versions and update paths + +### 4. Capability Manager (`packages/omnium-gatherum/src/services/capability-manager.ts`) + +Manages capability grants between caplets and kernel services: + +- **Methods**: + + - `requestCapability(capletId, capability)`: Request a capability grant + - `grantCapability(capletId, capability, target)`: Grant capability to caplet + - `revokeCapability(capletId, capability)`: Revoke a previously granted capability + - `listCapabilities(capletId)`: List all capabilities granted to a caplet + - `attenuateCapability(original, restrictions)`: Create an attenuated capability + +- **Implementation Notes**: + - Store capability grants in extension storage + - Capabilities are object references (KRefs) passed to caplets during bootstrap + - Support capability attenuation (time limits, scope restrictions) + - Track capability dependencies between caplets + +### 5. UI Renderer Service (`packages/omnium-gatherum/src/services/ui-renderer.ts`) + +Securely renders caplet UIs in isolated contexts: + +- **Methods**: + + - `renderCapletUI(capletId, mountPoint, container)`: Render a caplet's UI + - `unmountCapletUI(capletId)`: Remove a caplet's UI + - `createUICapability(capletId)`: Create a capability for UI rendering + +- **Implementation Approach**: + + - **Option A (Iframe-based)**: Render each caplet UI in a sandboxed iframe + - Use existing `makeIframeVatWorker` pattern but for UI rendering + - Create dedicated UI iframe HTML files per caplet + - Communicate via message ports (similar to vat communication) + - **Option B (Shadow DOM + React Portal)**: Use Shadow DOM for isolation + - Render React components in Shadow DOM containers + - Use React portals to mount caplet components + - Less isolation but simpler integration with React + +- **Recommendation**: Start with Option A (iframe-based) for stronger isolation, similar to how vats are isolated + +### 6. Host Shell UI (`packages/omnium-gatherum/src/ui/`) + +Main UI components for managing caplets: + +- **Components**: + + - `CapletStore.tsx`: Browse and install caplets from registries + - `InstalledCaplets.tsx`: List installed caplets, manage lifecycle + - `CapabilityManager.tsx`: View and manage capability grants + - `CapletSettings.tsx`: Configure individual caplet settings + - `HostShell.tsx`: Main shell that orchestrates UI placement + +- **Integration**: + - Extend existing `App.tsx` in `packages/omnium-gatherum/src/ui/App.tsx` + - Use `@metamask/kernel-ui` components for consistency + - Integrate with kernel RPC via existing `useKernelActions` pattern + +### 7. Caplet Bootstrap Service (`packages/omnium-gatherum/src/services/caplet-bootstrap.ts`) + +Coordinates caplet initialization and capability injection: + +- **Methods**: + + - `bootstrapCaplet(capletId, clusterConfig, capabilities)`: Launch caplet subcluster with capabilities + - `injectCapabilities(subclusterId, capabilities)`: Inject capabilities into running caplet + - `getCapletRoot(capletId)`: Get root object reference for a caplet + +- **Implementation Notes**: + - Use kernel's `launchSubcluster` API + - Pass capabilities as kernel services or via bootstrap parameters + - Track caplet subcluster IDs for lifecycle management + +### 8. Storage Service (`packages/omnium-gatherum/src/services/storage.ts`) + +Manages persistent state for caplets and host: + +- **Methods**: + + - `saveInstalledCaplets(caplets)`: Persist installed caplet list + - `loadInstalledCaplets()`: Load installed caplets on startup + - `saveCapabilityGrants(grants)`: Persist capability grants + - `loadCapabilityGrants()`: Load capability grants on startup + +- **Implementation Notes**: + - Use `chrome.storage.local` for browser extension + - Structure data for efficient querying + - Handle migration for schema changes + +## Implementation Order + +1. **Phase 1: Foundation** + + - Define caplet metadata schema (`types/caplet.ts`) + - Implement storage service + - Create basic host shell UI structure + +2. **Phase 2: Core Services** + + - Implement caplet registry service (npm support) + - Implement caplet installer + - Implement capability manager + +3. **Phase 3: UI Rendering** + + - Implement UI renderer service (iframe-based) + - Create UI iframe template for caplets + - Integrate UI rendering into host shell + +4. **Phase 4: Integration** + + - Implement caplet bootstrap service + - Connect all services in host shell + - Add caplet lifecycle management UI + +5. **Phase 5: Polish** + + - Add capability approval UI flows + - Implement caplet update mechanism + - Add error handling and user feedback + +## Key Design Decisions + +### Caplet Communication + +- Caplets communicate via CapTP using ocap URLs (as seen in `remote-comms.test.ts`) +- The host provides kernel services that caplets can request +- Caplets can request references to other caplets' root objects + +### UI Isolation Strategy + +- Use iframe-based isolation (similar to vat isolation) +- Each caplet UI runs in its own sandboxed iframe +- Communication via message ports with capability-based access control +- Host controls where UI is mounted (popup, sidebar, etc.) + +### Capability Model + +- Capabilities are object references (KRefs) passed during bootstrap +- Users approve capability requests before installation +- Capabilities can be revoked at any time +- Capabilities can be attenuated (time-limited, scope-restricted) + +## Files to Create/Modify + +### New Files + +- `packages/omnium-gatherum/src/types/caplet.ts` - Caplet type definitions +- `packages/omnium-gatherum/src/services/caplet-registry.ts` - Registry service +- `packages/omnium-gatherum/src/services/caplet-installer.ts` - Installer service +- `packages/omnium-gatherum/src/services/capability-manager.ts` - Capability management +- `packages/omnium-gatherum/src/services/ui-renderer.ts` - UI rendering service +- `packages/omnium-gatherum/src/services/caplet-bootstrap.ts` - Bootstrap service +- `packages/omnium-gatherum/src/services/storage.ts` - Storage service +- `packages/omnium-gatherum/src/ui/CapletStore.tsx` - Caplet store UI +- `packages/omnium-gatherum/src/ui/InstalledCaplets.tsx` - Installed caplets UI +- `packages/omnium-gatherum/src/ui/CapabilityManager.tsx` - Capability management UI +- `packages/omnium-gatherum/src/ui/HostShell.tsx` - Main shell component +- `packages/omnium-gatherum/src/ui/caplet-iframe.html` - Template for caplet UI iframes + +### Modified Files + +- `packages/omnium-gatherum/src/ui/App.tsx` - Integrate host shell +- `packages/omnium-gatherum/src/background.ts` - Initialize host services +- `packages/omnium-gatherum/src/offscreen.ts` - Ensure kernel is available for caplets + +## Testing Strategy + +- Unit tests for each service using vitest +- Integration tests for caplet installation and lifecycle +- E2E tests using Playwright for UI flows +- Test capability grants and revocation +- Test UI isolation between caplets + +## Open Questions + +1. **UI Framework**: Should caplets be required to use React, or support multiple frameworks? + + - Recommendation: Start with React support, add others later + +2. **Caplet Bundle Format**: Should caplets include UI code in the same bundle as vats, or separate? + + - Recommendation: Separate UI bundles initially for clarity + +3. **Capability Discovery**: How do caplets discover what capabilities are available? + + - Recommendation: Host provides a capability registry service that caplets can query + +4. **UI Mount Points**: What are the standard mount points for caplet UIs? + + - Recommendation: Start with 'popup', 'sidebar', 'modal', allow custom later