From 2d20e96be490bcc8ec223a9ae2746cf571063975 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:01:39 -0800 Subject: [PATCH 01/47] feat(ocap-kernel): Add KernelFacet --- packages/ocap-kernel/src/index.ts | 5 + packages/ocap-kernel/src/kernel-facet.test.ts | 201 +++++++++++++++ packages/ocap-kernel/src/kernel-facet.ts | 244 ++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 packages/ocap-kernel/src/kernel-facet.test.ts create mode 100644 packages/ocap-kernel/src/kernel-facet.ts diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index ee88fd403..11b209d8e 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -35,6 +35,11 @@ export { } from './types.ts'; export { kunser, kser, kslot, krefOf } from './liveslots/kernel-marshal.ts'; export type { SlotValue } from './liveslots/kernel-marshal.ts'; +export type { + KernelFacet, + KernelFacetLaunchResult, + KernelFacetRegisterSystemVatResult, +} from './kernel-facet.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts new file mode 100644 index 000000000..45c8c685d --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { KernelFacetDependencies } from './kernel-facet.ts'; +import { makeKernelFacet } from './kernel-facet.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; +import { krefOf } from './liveslots/kernel-marshal.ts'; +import type { ClusterConfig, KernelStatus, Subcluster } from './types.ts'; + +describe('makeKernelFacet', () => { + let deps: KernelFacetDependencies; + + beforeEach(() => { + deps = { + launchSubcluster: vi.fn().mockResolvedValue({ + subclusterId: 's1', + bootstrapRootKref: 'ko1', + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + reloadSubcluster: vi.fn().mockResolvedValue({ + id: 's2', + config: { bootstrap: 'test', vats: {} }, + vats: {}, + }), + getSubcluster: vi.fn().mockReturnValue({ + id: 's1', + config: { bootstrap: 'test', vats: {} }, + vats: {}, + }), + getSubclusters: vi + .fn() + .mockReturnValue([ + { id: 's1', config: { bootstrap: 'test', vats: {} }, vats: {} }, + ]), + getStatus: vi.fn().mockResolvedValue({ + initialized: true, + cranksExecuted: 10, + cranksPending: 0, + vatCount: 2, + endpointCount: 3, + }), + }; + }); + + it('creates a kernel facet object', () => { + const facet = makeKernelFacet(deps); + expect(facet).toBeDefined(); + expect(typeof facet).toBe('object'); + }); + + describe('launchSubcluster', () => { + it('calls the launchSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + launchSubcluster: (config: ClusterConfig) => Promise; + }; + const config: ClusterConfig = { + bootstrap: 'myVat', + vats: { myVat: { sourceSpec: 'test.js' } }, + }; + + await facet.launchSubcluster(config); + + expect(deps.launchSubcluster).toHaveBeenCalledWith(config); + }); + + it('returns subclusterId and root as slot value', async () => { + const facet = makeKernelFacet(deps) as { + launchSubcluster: ( + config: ClusterConfig, + ) => Promise<{ subclusterId: string; root: SlotValue }>; + }; + const config: ClusterConfig = { + bootstrap: 'myVat', + vats: { myVat: { sourceSpec: 'test.js' } }, + }; + + const result = await facet.launchSubcluster(config); + + expect(result.subclusterId).toBe('s1'); + // The root is a slot value (remotable) that carries the kref + expect(krefOf(result.root)).toBe('ko1'); + }); + }); + + describe('terminateSubcluster', () => { + it('calls the terminateSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + terminateSubcluster: (id: string) => Promise; + }; + + await facet.terminateSubcluster('s1'); + + expect(deps.terminateSubcluster).toHaveBeenCalledWith('s1'); + }); + }); + + describe('reloadSubcluster', () => { + it('calls the reloadSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + reloadSubcluster: (id: string) => Promise; + }; + + await facet.reloadSubcluster('s1'); + + expect(deps.reloadSubcluster).toHaveBeenCalledWith('s1'); + }); + + it('returns the reloaded subcluster', async () => { + const facet = makeKernelFacet(deps) as { + reloadSubcluster: (id: string) => Promise; + }; + + const result = await facet.reloadSubcluster('s1'); + + expect(result.id).toBe('s2'); + }); + }); + + describe('getSubcluster', () => { + it('calls the getSubcluster dependency', () => { + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + facet.getSubcluster('s1'); + + expect(deps.getSubcluster).toHaveBeenCalledWith('s1'); + }); + + it('returns the subcluster', () => { + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + const result = facet.getSubcluster('s1'); + + expect(result?.id).toBe('s1'); + }); + + it('returns undefined for unknown subcluster', () => { + vi.spyOn(deps, 'getSubcluster') + .mockImplementation() + .mockReturnValue(undefined); + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + const result = facet.getSubcluster('unknown'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getSubclusters', () => { + it('calls the getSubclusters dependency', () => { + const facet = makeKernelFacet(deps) as { + getSubclusters: () => Subcluster[]; + }; + + facet.getSubclusters(); + + expect(deps.getSubclusters).toHaveBeenCalled(); + }); + + it('returns all subclusters', () => { + const facet = makeKernelFacet(deps) as { + getSubclusters: () => Subcluster[]; + }; + + const result = facet.getSubclusters(); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('s1'); + }); + }); + + describe('getStatus', () => { + it('calls the getStatus dependency', async () => { + const facet = makeKernelFacet(deps) as { + getStatus: () => Promise; + }; + + await facet.getStatus(); + + expect(deps.getStatus).toHaveBeenCalled(); + }); + + it('returns kernel status', async () => { + const facet = makeKernelFacet(deps) as { + getStatus: () => Promise; + }; + + const result = await facet.getStatus(); + + expect(result.initialized).toBe(true); + expect(result.cranksExecuted).toBe(10); + expect(result.cranksPending).toBe(0); + expect(result.vatCount).toBe(2); + expect(result.endpointCount).toBe(3); + }); + }); +}); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts new file mode 100644 index 000000000..eed9865d6 --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -0,0 +1,244 @@ +import { makeDefaultExo } from '@metamask/kernel-utils'; + +import type { Kernel } from './Kernel.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; +import type { + ClusterConfig, + Subcluster, + KernelStatus, + SystemVatConfig, + SystemVatId, +} from './types.ts'; +import type { SystemVatManager } from './vats/SystemVatManager.ts'; + +/** + * Dependencies required to create a kernel facet. + */ +export type KernelFacetDependencies = Pick< + Kernel, + | 'launchSubcluster' + | 'terminateSubcluster' + | 'reloadSubcluster' + | 'getSubcluster' + | 'getSubclusters' + | 'getStatus' +> & { + /** Optional system vat manager for dynamic registration. */ + systemVatManager?: Pick; +}; + +/** + * Result of launching a subcluster via the kernel facet. + * Contains the root object as a slot value (which will become a presence) + * and the root kref string for storage purposes. + */ +export type KernelFacetLaunchResult = { + /** The ID of the launched subcluster. */ + subclusterId: string; + /** + * The root object as a slot value (becomes a presence when marshalled). + * Use this directly with E() for immediate operations. + */ + root: SlotValue; + /** + * The root kref string for storage purposes. + * Store this value to restore the presence after restart using getSubclusterRoot(). + */ + rootKref: string; +}; + +/** + * Result of registering a dynamic system vat via the kernel facet. + */ +export type KernelFacetRegisterSystemVatResult = { + /** The allocated system vat ID. */ + systemVatId: SystemVatId; + /** + * The root object as a slot value (becomes a presence when marshalled). + */ + root: SlotValue; + /** + * The root kref string for storage purposes. + */ + rootKref: string; + /** + * Function to disconnect and clean up the vat. + */ + disconnect: () => Promise; +}; + +/** + * The kernel facet interface. + * + * This is the interface provided as a vatpower to the bootstrap vat of a + * system vat. It enables privileged kernel operations. + * + * Derived from KernelFacetDependencies but with launchSubcluster overridden + * to return KernelFacetLaunchResult (root as SlotValue) instead of + * SubclusterLaunchResult (bootstrapRootKref as string). + */ +export type KernelFacet = Omit< + KernelFacetDependencies, + 'logger' | 'launchSubcluster' | 'systemVatManager' +> & { + /** + * Launch a dynamic subcluster. + * Returns root as a SlotValue (which becomes a presence when delivered). + * + * @param config - Configuration for the subcluster. + * @returns A promise for the launch result containing subclusterId and root presence. + */ + launchSubcluster: (config: ClusterConfig) => Promise; + + /** + * Register a system vat at runtime. + * Used by UIs and other components that connect after kernel initialization. + * + * @param config - Configuration for the system vat. + * @returns A promise for the registration result. + */ + registerSystemVat: ( + config: SystemVatConfig, + ) => Promise; + + /** + * Convert a kref string to a slot value (presence). + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @returns The slot value that will become a presence when marshalled. + */ + getVatRoot: (kref: string) => SlotValue; +}; + +/** + * Creates a kernel facet object that provides privileged kernel operations. + * + * The kernel facet is provided as a vatpower to the bootstrap vat of a + * system vat. It enables the bootstrap vat to: + * - Launch dynamic subclusters (and receive E()-callable presences) + * - Register dynamic system vats at runtime + * - Terminate subclusters + * - Reload subclusters + * - Query kernel status + * + * @param deps - Dependencies for creating the kernel facet. + * @returns The kernel facet object. + */ +export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { + const { + launchSubcluster, + terminateSubcluster, + reloadSubcluster, + getSubcluster, + getSubclusters, + getStatus, + systemVatManager, + } = deps; + + const kernelFacet = makeDefaultExo('kernelFacet', { + /** + * Launch a dynamic subcluster. + * + * @param config - Configuration for the subcluster. + * @returns A promise for the launch result containing subclusterId and root presence. + */ + async launchSubcluster( + config: ClusterConfig, + ): Promise { + const result = await launchSubcluster(config); + return { + subclusterId: result.subclusterId, + root: kslot(result.bootstrapRootKref, 'vatRoot'), + rootKref: result.bootstrapRootKref, + }; + }, + + /** + * Terminate a subcluster. + * + * @param subclusterId - ID of the subcluster to terminate. + */ + async terminateSubcluster(subclusterId: string): Promise { + await terminateSubcluster(subclusterId); + }, + + /** + * Reload a subcluster by terminating and relaunching all its vats. + * + * @param subclusterId - ID of the subcluster to reload. + * @returns The reloaded subcluster information. + */ + async reloadSubcluster(subclusterId: string): Promise { + return reloadSubcluster(subclusterId); + }, + + /** + * Get information about a specific subcluster. + * + * @param subclusterId - ID of the subcluster to query. + * @returns The subcluster information or undefined if not found. + */ + getSubcluster(subclusterId: string): Subcluster | undefined { + return getSubcluster(subclusterId); + }, + + /** + * Get information about all subclusters. + * + * @returns Array of all subcluster information records. + */ + getSubclusters(): Subcluster[] { + return getSubclusters(); + }, + + /** + * Get the current kernel status. + * + * @returns A promise for the kernel status. + */ + async getStatus(): Promise { + return getStatus(); + }, + + /** + * Register a system vat at runtime. + * Used by UIs and other components that connect after kernel initialization. + * + * @param config - Configuration for the system vat. + * @returns A promise for the registration result. + */ + async registerSystemVat( + config: SystemVatConfig, + ): Promise { + if (!systemVatManager) { + throw new Error( + 'Cannot register system vat: systemVatManager not provided to kernel facet', + ); + } + const result = await systemVatManager.registerSystemVat(config); + return { + systemVatId: result.systemVatId, + root: kslot(result.rootKref, 'vatRoot'), + rootKref: result.rootKref, + disconnect: result.disconnect, + }; + }, + + /** + * Convert a kref string to a slot value (presence). + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @returns The slot value that will become a presence when marshalled. + */ + getVatRoot(kref: string): SlotValue { + return kslot(kref, 'vatRoot'); + }, + }); + + return kernelFacet; +} From 7c314b37a27bf7cc39759b4cbe0db20b7b9ad4fd Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:43:51 -0800 Subject: [PATCH 02/47] feat(omnium-gatherum): Add system vats support Implement system vats that are launched at kernel initialization and have access to privileged kernel services. Key changes: - Add SystemVatConfig type and getSystemVatRoot method to Kernel - Launch system vats after queue starts to avoid deadlock - Terminate and relaunch existing system vat subclusters on restart - Add bootstrap-vat.js for Omnium system services with CapletController - Add baggage-backed storage adapter for vat persistence - Pass systemVats config via URL params from offscreen to kernel worker - Update background.ts to use system vat for caplet operations - Add process.env.NODE_ENV replacement in vat bundler for SES compatibility - Simplify kernel-facet.ts by removing SystemVatManager - Add duplicate name check in KernelServiceManager.registerKernelServiceObject Co-Authored-By: Claude Opus 4.5 --- packages/cli/src/vite/vat-bundler.ts | 4 + .../src/kernel-worker/captp/kernel-facade.ts | 8 ++ .../src/kernel-worker/kernel-worker.ts | 9 +- packages/kernel-browser-runtime/src/types.ts | 1 + packages/ocap-kernel/src/Kernel.ts | 83 ++++++++++- .../src/KernelServiceManager.test.ts | 11 ++ .../ocap-kernel/src/KernelServiceManager.ts | 3 + packages/ocap-kernel/src/index.ts | 7 +- packages/ocap-kernel/src/kernel-facet.ts | 73 +--------- packages/ocap-kernel/src/types.ts | 12 ++ packages/omnium-gatherum/README.md | 20 +++ packages/omnium-gatherum/package.json | 3 +- packages/omnium-gatherum/src/background.ts | 77 ++++++++--- packages/omnium-gatherum/src/offscreen.ts | 10 ++ .../omnium-gatherum/src/vats/bootstrap-vat.js | 95 +++++++++++++ .../src/vats/storage/baggage-adapter.test.ts | 129 ++++++++++++++++++ .../src/vats/storage/baggage-adapter.ts | 93 +++++++++++++ packages/omnium-gatherum/vite.config.ts | 6 + yarn.lock | 1 + 19 files changed, 542 insertions(+), 103 deletions(-) create mode 100644 packages/omnium-gatherum/src/vats/bootstrap-vat.js create mode 100644 packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts create mode 100644 packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts diff --git a/packages/cli/src/vite/vat-bundler.ts b/packages/cli/src/vite/vat-bundler.ts index 2de51c4d7..4538c44d6 100644 --- a/packages/cli/src/vite/vat-bundler.ts +++ b/packages/cli/src/vite/vat-bundler.ts @@ -20,6 +20,10 @@ export async function bundleVat(sourcePath: string): Promise { const result = await build({ configFile: false, logLevel: 'silent', + // Replace process.env references since they don't exist in SES vat environment + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, build: { write: false, lib: { diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts index 51d3cc9a4..f4cf12d84 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -42,6 +42,14 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { // TODO: Enable custom CapTP marshalling tables to convert this to a presence return { kref: krefString }; }, + + getSystemVatRoot: async (name: string) => { + const rootKref = kernel.getSystemVatRoot(name); + if (!rootKref) { + throw new Error(`System vat "${name}" not found`); + } + return { kref: rootKref }; + }, }); } harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index 02f12d83a..83b961c0a 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -47,7 +47,6 @@ async function main(): Promise { makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), ]); - // Set up console forwarding - messages flow through offscreen to background setupConsoleForwarding({ source: 'kernel-worker', onMessage: (message) => { @@ -55,12 +54,14 @@ async function main(): Promise { }, }); - const resetStorage = - new URLSearchParams(globalThis.location.search).get('reset-storage') === - 'true'; + const urlParams = new URLSearchParams(globalThis.location.search); + const resetStorage = urlParams.get('reset-storage') === 'true'; + const systemVatsParam = urlParams.get('system-vats'); + const systemVats = systemVatsParam ? JSON.parse(systemVatsParam) : undefined; const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { resetStorage, + systemVats, }); const handlerP = kernelP.then((kernel) => { diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index 02d014d2b..bd48cd07e 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -29,4 +29,5 @@ export type KernelFacade = { getStatus: Kernel['getStatus']; pingVat: Kernel['pingVat']; getVatRoot: (krefString: string) => Promise; + getSystemVatRoot: (name: string) => Promise<{ kref: string }>; }; diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index c9157c8c4..6f3809f12 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -2,6 +2,7 @@ import type { CapData } from '@endo/marshal'; import type { KernelDatabase } from '@metamask/kernel-store'; import { Logger } from '@metamask/logger'; +import { makeKernelFacet } from './kernel-facet.ts'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; @@ -23,6 +24,7 @@ import type { Subcluster, SubclusterLaunchResult, EndpointHandle, + SystemVatConfig, } from './types.ts'; import { isVatId, isRemoteId } from './types.ts'; import { SubclusterManager } from './vats/SubclusterManager.ts'; @@ -49,6 +51,9 @@ export class Kernel { /** Manages subcluster operations */ readonly #subclusterManager: SubclusterManager; + /** Stores root krefs of launched system vats */ + readonly #systemVatRoots: Map = new Map(); + /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -186,6 +191,7 @@ export class Kernel { * @param options.logger - Optional logger for error and diagnostic output. * @param options.keySeed - Optional seed for libp2p key generation. * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. + * @param options.systemVats - Optional array of system vat configurations to launch at init. * @returns A promise for the new kernel instance. */ static async make( @@ -196,17 +202,20 @@ export class Kernel { logger?: Logger; keySeed?: string | undefined; mnemonic?: string | undefined; + systemVats?: SystemVatConfig[]; } = {}, ): Promise { const kernel = new Kernel(platformServices, kernelDatabase, options); - await kernel.#init(); + await kernel.#init(options.systemVats); return kernel; } /** * Start the kernel running. + * + * @param systemVatConfigs - Optional array of system vat configurations to launch. */ - async #init(): Promise { + async #init(systemVatConfigs?: SystemVatConfig[]): Promise { // Set up the remote message handler this.#remoteManager.setMessageHandler( async (from: string, message: string) => @@ -219,6 +228,7 @@ export class Kernel { // Start the kernel queue processing (non-blocking) // This runs for the entire lifetime of the kernel + // Must start before launching system vats since launchSubcluster awaits bootstrap results this.#kernelQueue .run(this.#kernelRouter.deliver.bind(this.#kernelRouter)) .catch((error) => { @@ -228,6 +238,65 @@ export class Kernel { ); // Don't re-throw to avoid unhandled rejection in this long-running task }); + + // Launch system vats after queue is running + if (systemVatConfigs && systemVatConfigs.length > 0) { + // Ensure kernel facet is registered before launching system vats + this.#registerKernelFacet(); + await this.#launchSystemVats(systemVatConfigs); + } + } + + /** + * Launch system vats from their configurations. + * If a system vat subcluster already exists (from a previous session), + * it will be terminated and relaunched to ensure a clean state. + * + * @param configs - Array of system vat configurations. + */ + async #launchSystemVats(configs: SystemVatConfig[]): Promise { + for (const config of configs) { + const { name, services, ...vatConfig } = config; + + // Terminate any existing subcluster with the same bootstrap name + const existingSubcluster = this.getSubclusters().find( + (sc) => sc.config.bootstrap === name, + ); + if (existingSubcluster) { + this.#logger.info( + `Terminating existing system vat "${name}" for relaunch`, + ); + await this.terminateSubcluster(existingSubcluster.id); + } + + // Launch system vat + const clusterConfig: ClusterConfig = { + bootstrap: name, + vats: { [name]: vatConfig }, + ...(services && { services }), + }; + const result = await this.launchSubcluster(clusterConfig); + this.#systemVatRoots.set(name, result.bootstrapRootKref); + this.#logger.info(`System vat "${name}" launched`); + } + } + + /** + * Register the kernel facet as a kernel service for system vats. + */ + #registerKernelFacet(): void { + const kernelFacet = makeKernelFacet({ + launchSubcluster: this.launchSubcluster.bind(this), + terminateSubcluster: this.terminateSubcluster.bind(this), + reloadSubcluster: this.reloadSubcluster.bind(this), + getSubcluster: this.getSubcluster.bind(this), + getSubclusters: this.getSubclusters.bind(this), + getStatus: this.getStatus.bind(this), + }); + this.#kernelServiceManager.registerKernelServiceObject( + 'kernelFacet', + kernelFacet, + ); } /** @@ -342,6 +411,16 @@ export class Kernel { return this.#subclusterManager.getSubclusters(); } + /** + * Get the root kref of a system vat by name. + * + * @param name - The name of the system vat. + * @returns The root kref or undefined if not found. + */ + getSystemVatRoot(name: string): KRef | undefined { + return this.#systemVatRoots.get(name); + } + /** * Checks if a vat belongs to a specific subcluster. * diff --git a/packages/ocap-kernel/src/KernelServiceManager.test.ts b/packages/ocap-kernel/src/KernelServiceManager.test.ts index 0334437b2..a02ac6944 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.test.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.test.ts @@ -94,6 +94,17 @@ describe('KernelServiceManager', () => { expect(registered1.name).toBe('service1'); expect(registered2.name).toBe('service2'); }); + + it('throws when registering a service with a name that is already registered', () => { + const service1 = { method1: () => 'result1' }; + const service2 = { method2: () => 'result2' }; + + serviceManager.registerKernelServiceObject('duplicateName', service1); + + expect(() => + serviceManager.registerKernelServiceObject('duplicateName', service2), + ).toThrow('Kernel service "duplicateName" is already registered'); + }); }); describe('getKernelService', () => { diff --git a/packages/ocap-kernel/src/KernelServiceManager.ts b/packages/ocap-kernel/src/KernelServiceManager.ts index 5fcb74fb8..30be080dc 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.ts @@ -63,6 +63,9 @@ export class KernelServiceManager { * @returns The registered kernel service with its kref. */ registerKernelServiceObject(name: string, service: object): KernelService { + if (this.#kernelServicesByName.has(name)) { + throw new Error(`Kernel service "${name}" is already registered`); + } const serviceKey = `kernelService.${name}`; let kref = this.#kernelStore.kv.get(serviceKey); if (!kref) { diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 11b209d8e..d6636e4e2 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -15,6 +15,7 @@ export type { Subcluster, SubclusterId, SubclusterLaunchResult, + SystemVatConfig, } from './types.ts'; export type { RemoteMessageHandler, @@ -35,11 +36,7 @@ export { } from './types.ts'; export { kunser, kser, kslot, krefOf } from './liveslots/kernel-marshal.ts'; export type { SlotValue } from './liveslots/kernel-marshal.ts'; -export type { - KernelFacet, - KernelFacetLaunchResult, - KernelFacetRegisterSystemVatResult, -} from './kernel-facet.ts'; +export type { KernelFacet, KernelFacetLaunchResult } from './kernel-facet.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index eed9865d6..5e6f15483 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -3,14 +3,7 @@ import { makeDefaultExo } from '@metamask/kernel-utils'; import type { Kernel } from './Kernel.ts'; import { kslot } from './liveslots/kernel-marshal.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import type { - ClusterConfig, - Subcluster, - KernelStatus, - SystemVatConfig, - SystemVatId, -} from './types.ts'; -import type { SystemVatManager } from './vats/SystemVatManager.ts'; +import type { ClusterConfig, Subcluster, KernelStatus } from './types.ts'; /** * Dependencies required to create a kernel facet. @@ -23,10 +16,7 @@ export type KernelFacetDependencies = Pick< | 'getSubcluster' | 'getSubclusters' | 'getStatus' -> & { - /** Optional system vat manager for dynamic registration. */ - systemVatManager?: Pick; -}; +>; /** * Result of launching a subcluster via the kernel facet. @@ -48,26 +38,6 @@ export type KernelFacetLaunchResult = { rootKref: string; }; -/** - * Result of registering a dynamic system vat via the kernel facet. - */ -export type KernelFacetRegisterSystemVatResult = { - /** The allocated system vat ID. */ - systemVatId: SystemVatId; - /** - * The root object as a slot value (becomes a presence when marshalled). - */ - root: SlotValue; - /** - * The root kref string for storage purposes. - */ - rootKref: string; - /** - * Function to disconnect and clean up the vat. - */ - disconnect: () => Promise; -}; - /** * The kernel facet interface. * @@ -80,7 +50,7 @@ export type KernelFacetRegisterSystemVatResult = { */ export type KernelFacet = Omit< KernelFacetDependencies, - 'logger' | 'launchSubcluster' | 'systemVatManager' + 'logger' | 'launchSubcluster' > & { /** * Launch a dynamic subcluster. @@ -91,17 +61,6 @@ export type KernelFacet = Omit< */ launchSubcluster: (config: ClusterConfig) => Promise; - /** - * Register a system vat at runtime. - * Used by UIs and other components that connect after kernel initialization. - * - * @param config - Configuration for the system vat. - * @returns A promise for the registration result. - */ - registerSystemVat: ( - config: SystemVatConfig, - ) => Promise; - /** * Convert a kref string to a slot value (presence). * @@ -119,7 +78,6 @@ export type KernelFacet = Omit< * The kernel facet is provided as a vatpower to the bootstrap vat of a * system vat. It enables the bootstrap vat to: * - Launch dynamic subclusters (and receive E()-callable presences) - * - Register dynamic system vats at runtime * - Terminate subclusters * - Reload subclusters * - Query kernel status @@ -135,7 +93,6 @@ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { getSubcluster, getSubclusters, getStatus, - systemVatManager, } = deps; const kernelFacet = makeDefaultExo('kernelFacet', { @@ -203,30 +160,6 @@ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { return getStatus(); }, - /** - * Register a system vat at runtime. - * Used by UIs and other components that connect after kernel initialization. - * - * @param config - Configuration for the system vat. - * @returns A promise for the registration result. - */ - async registerSystemVat( - config: SystemVatConfig, - ): Promise { - if (!systemVatManager) { - throw new Error( - 'Cannot register system vat: systemVatManager not provided to kernel facet', - ); - } - const result = await systemVatManager.registerSystemVat(config); - return { - systemVatId: result.systemVatId, - root: kslot(result.rootKref, 'vatRoot'), - rootKref: result.rootKref, - disconnect: result.disconnect, - }; - }, - /** * Convert a kref string to a slot value (presence). * diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 550232c00..6c01e40ff 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -538,3 +538,15 @@ export type EndpointHandle = { deliverRetireImports: (erefs: ERef[]) => Promise; deliverBringOutYourDead: () => Promise; }; + +/** + * Configuration for a system vat. + * System vats are statically declared at kernel initialization and can + * receive powerful kernel services not available to normal vats. + */ +export type SystemVatConfig = VatConfig & { + /** Unique name for this system vat (used for retrieval via getSystemVatRoot) */ + name: string; + /** Array of kernel service names this system vat requires */ + services?: string[]; +}; diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index 688955bae..dc4449030 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -10,6 +10,26 @@ or `npm install @ocap/omnium-gatherum` +## Usage + +### Installing and using the `echo` caplet + +After loading the extension, open the background console (chrome://extensions → Omnium → "Inspect views: service worker") and run the following: + +```javascript +// 1. Load the echo caplet manifest +const { manifest } = await omnium.caplet.load('echo'); + +// 2. Install the caplet +await omnium.caplet.install(manifest); + +// 3. List installed caplets +await omnium.caplet.list(); + +// 4. Get a specific caplet +await omnium.caplet.get('echo'); +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 36bf371b4..326b790b8 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -20,7 +20,7 @@ "build:dev": "yarn build:vite --mode development", "build:watch": "yarn build:dev --watch", "build:browser": "OPEN_BROWSER=true yarn build:dev --watch", - "build:caplets": "ocap bundle src/caplets/echo", + "build:caplets": "ocap bundle src/caplets/echo && ocap bundle src/vats/bootstrap-vat.js", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/omnium-gatherum", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", @@ -47,6 +47,7 @@ "dependencies": { "@endo/eventual-send": "^1.3.4", "@endo/exo": "^1.5.12", + "@endo/promise-kit": "^1.1.13", "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 191160ec6..27110525d 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -5,16 +5,19 @@ import { isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; +import type { + CapTPMessage, + KernelFacade, +} from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import { initializeControllers } from './controllers/index.ts'; import type { - CapletControllerFacet, CapletManifest, + InstalledCaplet, + InstallResult, } from './controllers/index.ts'; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; @@ -106,17 +109,19 @@ async function main(): Promise { }); const kernelP = backgroundCapTP.getKernel(); - globalThis.kernel = kernelP; + globals.setKernel(kernelP); - try { - const controllers = await initializeControllers({ - logger, - kernel: kernelP, + // Set up bootstrap vat initialization (runs concurrently with stream drain) + E(kernelP) + .getSystemVatRoot('omnium-bootstrap') + .then(({ kref }) => { + globals.setBootstrapKref(kref); + logger.info('Bootstrap vat initialized'); + return undefined; + }) + .catch((error) => { + logger.error('Failed to initialize bootstrap vat:', error); }); - globals.setCapletController(controllers.caplet); - } catch (error) { - offscreenStream.throw(error as Error).catch(logger.error); - } try { await offscreenStream.drain((message) => { @@ -137,7 +142,8 @@ async function main(): Promise { } type GlobalSetters = { - setCapletController: (value: CapletControllerFacet) => void; + setKernel: (kernel: KernelFacade | Promise) => void; + setBootstrapKref: (kref: string) => void; }; /** @@ -146,7 +152,32 @@ type GlobalSetters = { * @returns A device for setting the global values. */ function defineGlobals(): GlobalSetters { - let capletController: CapletControllerFacet; + let bootstrapKref: string; + + /** + * Call a method on the bootstrap vat via queueMessage. + * + * @param method - The method name to call. + * @param args - Arguments to pass to the method. + * @returns The result from the bootstrap vat. + */ + const callBootstrap = async ( + method: string, + args: unknown[] = [], + ): Promise => { + if (!kernel) { + throw new Error('Kernel facade not initialized'); + } + if (!bootstrapKref) { + throw new Error('Bootstrap vat not initialized'); + } + + const capData = await E(kernel).queueMessage(bootstrapKref, method, args); + // CapData body is JSON-stringified; parse it to get the actual value + // return JSON.parse(capData.body) as T; + // @ts-expect-error - CapData is not assignable to T + return capData; + }; Object.defineProperty(globalThis, 'E', { configurable: false, @@ -210,22 +241,26 @@ function defineGlobals(): GlobalSetters { caplet: { value: harden({ install: async (manifest: CapletManifest) => - E(capletController).install(manifest), + callBootstrap('installCaplet', [manifest]), uninstall: async (capletId: string) => - E(capletController).uninstall(capletId), - list: async () => E(capletController).list(), + callBootstrap('uninstallCaplet', [capletId]), + list: async () => callBootstrap('listCaplets'), load: loadCaplet, - get: async (capletId: string) => E(capletController).get(capletId), + get: async (capletId: string) => + callBootstrap('getCaplet', [capletId]), getCapletRoot: async (capletId: string) => - E(capletController).getCapletRoot(capletId), + callBootstrap('getCapletRoot', [capletId]), }), }, }); harden(globalThis.omnium); return { - setCapletController: (value) => { - capletController = value; + setKernel: (kernel) => { + globalThis.kernel = kernel; + }, + setBootstrapKref: (kref) => { + bootstrapKref = kref; }, }; } diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 0cf807894..8339e8ed3 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -56,6 +56,16 @@ async function makeKernelWorker(): Promise< const workerUrlParams = new URLSearchParams(relayQueryString); workerUrlParams.set('reset-storage', process.env.RESET_STORAGE ?? 'false'); + // Configure system vats to launch at kernel initialization + const systemVats = [ + { + name: 'omnium-bootstrap', + bundleSpec: chrome.runtime.getURL('bootstrap-vat-bundle.json'), + services: ['kernelFacet'], + }, + ]; + workerUrlParams.set('system-vats', JSON.stringify(systemVats)); + const workerUrl = new URL('kernel-worker.js', import.meta.url); workerUrl.search = workerUrlParams.toString(); diff --git a/packages/omnium-gatherum/src/vats/bootstrap-vat.js b/packages/omnium-gatherum/src/vats/bootstrap-vat.js new file mode 100644 index 000000000..d8843dd35 --- /dev/null +++ b/packages/omnium-gatherum/src/vats/bootstrap-vat.js @@ -0,0 +1,95 @@ +import { E } from '@endo/eventual-send'; +import { makePromiseKit } from '@endo/promise-kit'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; +import { CapletController } from '../controllers/caplet/caplet-controller.ts'; + +/** + * Bootstrap vat for Omnium system services. + * Hosts controllers with baggage-backed persistence. + * + * Methods are exposed directly on root (not nested) for queueMessage access. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} _parameters - Initialization parameters (unused). + * @param {object} baggage - Root of vat's persistent state. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject(vatPowers, _parameters, baggage) { + const logger = + vatPowers.logger?.subLogger({ tags: ['bootstrap'] }) ?? console; + + // Create baggage-backed storage adapter + const storageAdapter = makeBaggageStorageAdapter(baggage); + + // Promise kit for the caplet controller facet, resolved in bootstrap() + /** @type {import('@endo/promise-kit').PromiseKit} */ + const { promise: capletFacetP, resolve: resolveCapletFacet } = + makePromiseKit(); + + // Define delegating methods for caplet operations + const capletMethods = defineMethods(capletFacetP, { + installCaplet: 'install', + uninstallCaplet: 'uninstall', + listCaplets: 'list', + getCaplet: 'get', + getCapletRoot: 'getCapletRoot', + }); + + return makeDefaultExo('omnium-bootstrap', { + /** + * Initialize the bootstrap vat with services from the kernel. + * + * @param {object} _vats - Other vats in this subcluster (unused). + * @param {object} services - Services provided by the kernel. + */ + async bootstrap(_vats, services) { + logger?.info('Bootstrap called'); + + const { kernelFacet } = services; + if (!kernelFacet) { + throw new Error('kernelFacet service is required'); + } + + // Initialize caplet controller with baggage-backed storage + const capletFacet = await CapletController.make( + { logger: logger?.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async (config) => { + const result = await E(kernelFacet).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; + }, + terminateSubcluster: (subclusterId) => + E(kernelFacet).terminateSubcluster(subclusterId), + getVatRoot: (krefString) => E(kernelFacet).getVatRoot(krefString), + }, + ); + resolveCapletFacet(capletFacet); + + logger?.info('Bootstrap complete'); + }, + + ...capletMethods, + }); +} + +/** + * Create delegating methods that forward calls to a source object via E(). + * Useful for exposing methods from a promise-based source on an exo. + * + * @param {object | Promise} source - The source object (or promise) to delegate to. + * @param {Record} methodMap - Maps exposed method names to source method names. + * @returns {Record unknown>} An object with delegating methods. + */ +function defineMethods(source, methodMap) { + const output = {}; + for (const [exposedName, sourceName] of Object.entries(methodMap)) { + output[exposedName] = (...args) => E(source)[sourceName](...args); + } + return output; +} diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts new file mode 100644 index 000000000..306a2951a --- /dev/null +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { makeBaggageStorageAdapter } from './baggage-adapter.ts'; + +/** + * Create a mock baggage store for testing. + * + * @returns A mock baggage implementation. + */ +function makeMockBaggage() { + const store = new Map(); + return { + has: vi.fn((key: string) => store.has(key)), + get: vi.fn((key: string) => store.get(key)), + init: vi.fn((key: string, value: unknown) => { + if (store.has(key)) { + throw new Error(`Key "${key}" already exists`); + } + store.set(key, value); + }), + set: vi.fn((key: string, value: unknown) => { + if (!store.has(key)) { + throw new Error(`Key "${key}" does not exist`); + } + store.set(key, value); + }), + _store: store, // For test inspection + }; +} + +describe('makeBaggageStorageAdapter', () => { + let baggage: ReturnType; + let adapter: ReturnType; + + beforeEach(() => { + baggage = makeMockBaggage(); + adapter = makeBaggageStorageAdapter(baggage); + }); + + describe('get', () => { + it('returns undefined for non-existent key', async () => { + const result = await adapter.get('nonexistent'); + expect(result).toBeUndefined(); + }); + + it('returns stored value', async () => { + baggage._store.set('test-key', { foo: 'bar' }); + const result = await adapter.get('test-key'); + expect(result).toStrictEqual({ foo: 'bar' }); + }); + }); + + describe('set', () => { + it('initializes new key', async () => { + await adapter.set('new-key', { value: 123 }); + expect(baggage.init).toHaveBeenCalled(); + expect(baggage._store.get('new-key')).toStrictEqual({ value: 123 }); + }); + + it('updates existing key', async () => { + // First set + await adapter.set('existing-key', { value: 1 }); + + // Second set should use baggage.set, not init + await adapter.set('existing-key', { value: 2 }); + + expect(baggage.set).toHaveBeenCalled(); + expect(baggage._store.get('existing-key')).toStrictEqual({ value: 2 }); + }); + + it('tracks keys', async () => { + await adapter.set('key1', 'value1'); + await adapter.set('key2', 'value2'); + + const keys = await adapter.keys(); + expect(keys).toContain('key1'); + expect(keys).toContain('key2'); + }); + }); + + describe('delete', () => { + it('sets value to null and removes from key list', async () => { + await adapter.set('to-delete', { data: 'test' }); + await adapter.delete('to-delete'); + + expect(baggage._store.get('to-delete')).toBeNull(); + const keys = await adapter.keys(); + expect(keys).not.toContain('to-delete'); + }); + + it('does nothing for non-existent key', async () => { + // Should not throw and keys should remain empty + await adapter.delete('nonexistent'); + const keys = await adapter.keys(); + expect(keys).toStrictEqual([]); + }); + }); + + describe('keys', () => { + it('returns empty array when no keys stored', async () => { + const keys = await adapter.keys(); + expect(keys).toStrictEqual([]); + }); + + it('returns all keys', async () => { + await adapter.set('alpha', 'a'); + await adapter.set('beta', 'b'); + await adapter.set('gamma', 'c'); + + const keys = await adapter.keys(); + expect(keys).toHaveLength(3); + expect(keys).toContain('alpha'); + expect(keys).toContain('beta'); + expect(keys).toContain('gamma'); + }); + + it('filters by prefix', async () => { + await adapter.set('caplet:foo', 'foo'); + await adapter.set('caplet:bar', 'bar'); + await adapter.set('other:baz', 'baz'); + + const capletKeys = await adapter.keys('caplet:'); + expect(capletKeys).toHaveLength(2); + expect(capletKeys).toContain('caplet:foo'); + expect(capletKeys).toContain('caplet:bar'); + expect(capletKeys).not.toContain('other:baz'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts new file mode 100644 index 000000000..9fc4e0f39 --- /dev/null +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -0,0 +1,93 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from '../../controllers/storage/types.ts'; + +/** + * Baggage interface from liveslots. + * Baggage provides durable storage for vat state. + */ +type Baggage = { + has: (key: string) => boolean; + get: (key: string) => unknown; + init: (key: string, value: unknown) => void; + set: (key: string, value: unknown) => void; +}; + +const KEYS_KEY = '__storage_keys__'; + +/** + * Create a StorageAdapter implementation backed by vat baggage. + * Provides synchronous persistence (baggage writes are durable). + * + * Since baggage doesn't support key enumeration directly, we track + * stored keys in a separate baggage entry. + * + * @param baggage - The vat baggage store. + * @returns A StorageAdapter backed by baggage. + */ +export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { + /** + * Get all tracked storage keys. + * + * @returns The set of tracked keys. + */ + const getKeys = (): Set => { + if (baggage.has(KEYS_KEY)) { + return new Set(baggage.get(KEYS_KEY) as string[]); + } + return new Set(); + }; + + /** + * Save the set of tracked keys to baggage. + * + * @param keys - The set of keys to save. + */ + const saveKeys = (keys: Set): void => { + const arr = Array.from(keys); + if (baggage.has(KEYS_KEY)) { + baggage.set(KEYS_KEY, harden(arr)); + } else { + baggage.init(KEYS_KEY, harden(arr)); + } + }; + + return harden({ + async get(key: string): Promise { + if (baggage.has(key)) { + return baggage.get(key) as Value; + } + return undefined; + }, + + async set(key: string, value: Json): Promise { + const keys = getKeys(); + if (baggage.has(key)) { + baggage.set(key, harden(value)); + } else { + baggage.init(key, harden(value)); + keys.add(key); + saveKeys(keys); + } + }, + + async delete(key: string): Promise { + // Baggage doesn't support true deletion, so we set to null marker + if (baggage.has(key)) { + baggage.set(key, harden(null)); + const keys = getKeys(); + keys.delete(key); + saveKeys(keys); + } + }, + + async keys(prefix?: string): Promise { + const allKeys = getKeys(); + if (!prefix) { + return Array.from(allKeys); + } + return Array.from(allKeys).filter((k) => k.startsWith(prefix)); + }, + }); +} +harden(makeBaggageStorageAdapter); diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 57abda3b8..fe3c1da72 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,6 +38,12 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', + // Bootstrap vat bundle (system vat for kernel services) + { + src: 'packages/omnium-gatherum/src/vats/bootstrap-vat.bundle', + dest: './', + rename: 'bootstrap-vat-bundle.json', + }, // Caplets (add new caplet entries here) { src: 'packages/omnium-gatherum/src/caplets/echo/{manifest.json,*.bundle}', diff --git a/yarn.lock b/yarn.lock index c5cbd044b..5f1d6a6fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3927,6 +3927,7 @@ __metadata: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.4" "@endo/exo": "npm:^1.5.12" + "@endo/promise-kit": "npm:^1.1.13" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" From 10cc18e2b5d2c156d43ac8ad49fa3602b386d057 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:55:23 -0800 Subject: [PATCH 03/47] refactor(omnium-gatherum): Convert bootstrap-vat to TypeScript - Rename bootstrap-vat.js to bootstrap-vat.ts with full type annotations - Export Baggage type from baggage-adapter.ts - Make logger optional throughout controller hierarchy - Simplify defineMethods to take array of method names instead of object map - Update background.ts to use simplified method names (install, uninstall, etc.) - Update package.json build script to reference .ts file Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/package.json | 2 +- packages/omnium-gatherum/src/background.ts | 8 +- .../src/controllers/base-controller.ts | 12 +- .../controllers/caplet/caplet-controller.ts | 14 +- .../controllers/storage/controller-storage.ts | 8 +- .../omnium-gatherum/src/vats/bootstrap-vat.js | 95 ------------ .../omnium-gatherum/src/vats/bootstrap-vat.ts | 144 ++++++++++++++++++ .../src/vats/storage/baggage-adapter.ts | 2 +- 8 files changed, 167 insertions(+), 118 deletions(-) delete mode 100644 packages/omnium-gatherum/src/vats/bootstrap-vat.js create mode 100644 packages/omnium-gatherum/src/vats/bootstrap-vat.ts diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 326b790b8..cbcd17d95 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -20,7 +20,7 @@ "build:dev": "yarn build:vite --mode development", "build:watch": "yarn build:dev --watch", "build:browser": "OPEN_BROWSER=true yarn build:dev --watch", - "build:caplets": "ocap bundle src/caplets/echo && ocap bundle src/vats/bootstrap-vat.js", + "build:caplets": "ocap bundle src/caplets/echo && ocap bundle src/vats/bootstrap-vat.ts", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/omnium-gatherum", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 27110525d..176d6c9e5 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -241,13 +241,13 @@ function defineGlobals(): GlobalSetters { caplet: { value: harden({ install: async (manifest: CapletManifest) => - callBootstrap('installCaplet', [manifest]), + callBootstrap('install', [manifest]), uninstall: async (capletId: string) => - callBootstrap('uninstallCaplet', [capletId]), - list: async () => callBootstrap('listCaplets'), + callBootstrap('uninstall', [capletId]), + list: async () => callBootstrap('list'), load: loadCaplet, get: async (capletId: string) => - callBootstrap('getCaplet', [capletId]), + callBootstrap('get', [capletId]), getCapletRoot: async (capletId: string) => callBootstrap('getCapletRoot', [capletId]), }), diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts index d53c1eb3a..c2ff095ca 100644 --- a/packages/omnium-gatherum/src/controllers/base-controller.ts +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -13,7 +13,7 @@ export type ControllerMethods = Record unknown>; * Configuration passed to all controllers during initialization. */ export type ControllerConfig = { - logger: Logger; + logger?: Logger | undefined; }; /** @@ -59,19 +59,19 @@ export abstract class Controller< readonly #storage: ControllerStorage; - readonly #logger: Logger; + readonly #logger: Logger | undefined; /** * Protected constructor - subclasses must call this via super(). * * @param name - Controller name for debugging/logging. * @param storage - ControllerStorage instance for state management. - * @param logger - Logger instance. + * @param logger - Optional logger instance. */ protected constructor( name: ControllerName, storage: ControllerStorage, - logger: Logger, + logger?: Logger, ) { this.#name = name; this.#storage = storage; @@ -101,9 +101,9 @@ export abstract class Controller< /** * Logger instance for this controller. * - * @returns The logger instance. + * @returns The logger instance, or undefined if not provided. */ - protected get logger(): Logger { + protected get logger(): Logger | undefined { return this.#logger; } diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 5a1f929d0..a63017b7f 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -117,7 +117,7 @@ export class CapletController extends Controller< * Private constructor - use static create() method. * * @param storage - ControllerStorage for caplet state. - * @param logger - Logger instance. + * @param logger - Optional logger instance. * @param launchSubcluster - Function to launch a subcluster. * @param terminateSubcluster - Function to terminate a subcluster. * @param getVatRoot - Function to get a vat's root object as a presence. @@ -125,7 +125,7 @@ export class CapletController extends Controller< // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors private constructor( storage: ControllerStorage, - logger: Logger, + logger: Logger | undefined, launchSubcluster: (config: ClusterConfig) => Promise, terminateSubcluster: (subclusterId: string) => Promise, getVatRoot: (krefString: string) => Promise, @@ -153,7 +153,7 @@ export class CapletController extends Controller< namespace: 'caplet', adapter: deps.adapter, makeDefaultState: () => ({ caplets: {} }), - logger: config.logger.subLogger({ tags: ['storage'] }), + logger: config.logger?.subLogger({ tags: ['storage'] }), }); const controller = new CapletController( @@ -203,7 +203,7 @@ export class CapletController extends Controller< _bundle?: unknown, ): Promise { const { id } = manifest; - this.logger.info(`Installing caplet: ${id}`); + this.logger?.info(`Installing caplet: ${id}`); // TODO: Move this validation in front of the controller. if (!isCapletManifest(manifest)) { @@ -241,7 +241,7 @@ export class CapletController extends Controller< }; }); - this.logger.info( + this.logger?.info( `Caplet ${id} installed with subcluster ${subclusterId}`, ); return { capletId: id, subclusterId }; @@ -256,7 +256,7 @@ export class CapletController extends Controller< * @param capletId - The ID of the caplet to uninstall. */ async #uninstall(capletId: CapletId): Promise { - this.logger.info(`Uninstalling caplet: ${capletId}`); + this.logger?.info(`Uninstalling caplet: ${capletId}`); const caplet = this.state.caplets[capletId]; if (caplet === undefined) { @@ -269,7 +269,7 @@ export class CapletController extends Controller< delete draft.caplets[capletId]; }); - this.logger.info(`Caplet ${capletId} uninstalled`); + this.logger?.info(`Caplet ${capletId} uninstalled`); } /** diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts index a86e90197..e0eb428c2 100644 --- a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -22,8 +22,8 @@ export type ControllerStorageConfig> = { adapter: StorageAdapter; /** Default state values - used for initialization and type inference */ makeDefaultState: () => State; - /** Logger for storage operations */ - logger: Logger; + /** Optional logger for storage operations */ + logger?: Logger | undefined; /** Debounce delay in milliseconds (default: 100, set to 0 for tests) */ debounceMs?: number; }; @@ -57,7 +57,7 @@ export class ControllerStorage> { readonly #makeDefaultState: () => State; - readonly #logger: Logger; + readonly #logger: Logger | undefined; readonly #debounceMs: number; @@ -251,7 +251,7 @@ export class ControllerStorage> { // Persist current state values for accumulated keys this.#persistAccumulatedKeys(this.#state, keysToWrite).catch((error) => { - this.#logger.error('Failed to persist state changes:', error); + this.#logger?.error('Failed to persist state changes:', error); }); } diff --git a/packages/omnium-gatherum/src/vats/bootstrap-vat.js b/packages/omnium-gatherum/src/vats/bootstrap-vat.js deleted file mode 100644 index d8843dd35..000000000 --- a/packages/omnium-gatherum/src/vats/bootstrap-vat.js +++ /dev/null @@ -1,95 +0,0 @@ -import { E } from '@endo/eventual-send'; -import { makePromiseKit } from '@endo/promise-kit'; -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; - -import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; -import { CapletController } from '../controllers/caplet/caplet-controller.ts'; - -/** - * Bootstrap vat for Omnium system services. - * Hosts controllers with baggage-backed persistence. - * - * Methods are exposed directly on root (not nested) for queueMessage access. - * - * @param {object} vatPowers - Special powers granted to this vat. - * @param {object} _parameters - Initialization parameters (unused). - * @param {object} baggage - Root of vat's persistent state. - * @returns {object} The root object for the new vat. - */ -export function buildRootObject(vatPowers, _parameters, baggage) { - const logger = - vatPowers.logger?.subLogger({ tags: ['bootstrap'] }) ?? console; - - // Create baggage-backed storage adapter - const storageAdapter = makeBaggageStorageAdapter(baggage); - - // Promise kit for the caplet controller facet, resolved in bootstrap() - /** @type {import('@endo/promise-kit').PromiseKit} */ - const { promise: capletFacetP, resolve: resolveCapletFacet } = - makePromiseKit(); - - // Define delegating methods for caplet operations - const capletMethods = defineMethods(capletFacetP, { - installCaplet: 'install', - uninstallCaplet: 'uninstall', - listCaplets: 'list', - getCaplet: 'get', - getCapletRoot: 'getCapletRoot', - }); - - return makeDefaultExo('omnium-bootstrap', { - /** - * Initialize the bootstrap vat with services from the kernel. - * - * @param {object} _vats - Other vats in this subcluster (unused). - * @param {object} services - Services provided by the kernel. - */ - async bootstrap(_vats, services) { - logger?.info('Bootstrap called'); - - const { kernelFacet } = services; - if (!kernelFacet) { - throw new Error('kernelFacet service is required'); - } - - // Initialize caplet controller with baggage-backed storage - const capletFacet = await CapletController.make( - { logger: logger?.subLogger({ tags: ['caplet'] }) }, - { - adapter: storageAdapter, - launchSubcluster: async (config) => { - const result = await E(kernelFacet).launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootKref: result.rootKref, - }; - }, - terminateSubcluster: (subclusterId) => - E(kernelFacet).terminateSubcluster(subclusterId), - getVatRoot: (krefString) => E(kernelFacet).getVatRoot(krefString), - }, - ); - resolveCapletFacet(capletFacet); - - logger?.info('Bootstrap complete'); - }, - - ...capletMethods, - }); -} - -/** - * Create delegating methods that forward calls to a source object via E(). - * Useful for exposing methods from a promise-based source on an exo. - * - * @param {object | Promise} source - The source object (or promise) to delegate to. - * @param {Record} methodMap - Maps exposed method names to source method names. - * @returns {Record unknown>} An object with delegating methods. - */ -function defineMethods(source, methodMap) { - const output = {}; - for (const [exposedName, sourceName] of Object.entries(methodMap)) { - output[exposedName] = (...args) => E(source)[sourceName](...args); - } - return output; -} diff --git a/packages/omnium-gatherum/src/vats/bootstrap-vat.ts b/packages/omnium-gatherum/src/vats/bootstrap-vat.ts new file mode 100644 index 000000000..525bdc8d4 --- /dev/null +++ b/packages/omnium-gatherum/src/vats/bootstrap-vat.ts @@ -0,0 +1,144 @@ +import { E } from '@endo/eventual-send'; +import type { ERef } from '@endo/eventual-send'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { PromiseKit } from '@endo/promise-kit'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; +import type { Baggage } from './storage/baggage-adapter.ts'; +import { CapletController } from '../controllers/caplet/caplet-controller.ts'; +import type { + CapletControllerFacet, + LaunchResult, +} from '../controllers/caplet/index.ts'; + +/** + * Vat powers provided to the bootstrap vat. + */ +type VatPowers = { + logger?: Logger; +}; + +/** + * Kernel facet interface for system vat operations. + */ +type KernelFacet = { + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; + getVatRoot: (krefString: string) => Promise; +}; + +/** + * Services provided to the bootstrap vat. + */ +type BootstrapServices = { + kernelFacet?: KernelFacet; +}; + +/** + * Bootstrap vat for Omnium system services. + * Hosts controllers with baggage-backed persistence. + * + * Methods are exposed directly on root (not nested) for queueMessage access. + * + * @param vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters (unused). + * @param baggage - Root of vat's persistent state. + * @returns The root object for the new vat. + */ +export function buildRootObject( + vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + const vatLogger = vatPowers.logger?.subLogger({ tags: ['bootstrap'] }); + const logger = vatLogger ?? console; + + // Create baggage-backed storage adapter + const storageAdapter = makeBaggageStorageAdapter(baggage); + + // Promise kit for the caplet controller facet, resolved in bootstrap() + const { + promise: capletFacetP, + resolve: resolveCapletFacet, + }: PromiseKit = + makePromiseKit(); + + // Define delegating methods for caplet operations + const capletMethods = defineMethods(capletFacetP, [ + 'install', + 'uninstall', + 'list', + 'get', + 'getCapletRoot', + ]); + + return makeDefaultExo('omnium-bootstrap', { + /** + * Initialize the bootstrap vat with services from the kernel. + * + * @param _vats - Other vats in this subcluster (unused). + * @param services - Services provided by the kernel. + */ + async bootstrap( + _vats: unknown, + services: BootstrapServices, + ): Promise { + logger?.info('Bootstrap called'); + + const { kernelFacet } = services; + if (!kernelFacet) { + throw new Error('kernelFacet service is required'); + } + + // Initialize caplet controller with baggage-backed storage + const capletFacet = await CapletController.make( + { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + const result = await E(kernelFacet).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; + }, + terminateSubcluster: async (subclusterId: string): Promise => + E(kernelFacet).terminateSubcluster(subclusterId), + getVatRoot: async (krefString: string): Promise => + E(kernelFacet).getVatRoot(krefString), + }, + ); + resolveCapletFacet(capletFacet); + + logger?.info('Bootstrap complete'); + }, + + ...capletMethods, + }); +} + +/** + * Create delegating methods that forward calls to a source object via E(). + * Useful for exposing methods from a promise-based source on an exo. + * + * @param source - The source object (or promise) to delegate to. + * @param methodNames - Array of method names to delegate. + * @returns An object with delegating methods. + */ +function defineMethods( + source: ERef, + methodNames: string[], +): Record unknown> { + const output: Record unknown> = {}; + for (const methodName of methodNames) { + output[methodName] = (...args: unknown[]) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (E(source) as any)[methodName](...args); + } + return output; +} diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts index 9fc4e0f39..fbd203f84 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -6,7 +6,7 @@ import type { StorageAdapter } from '../../controllers/storage/types.ts'; * Baggage interface from liveslots. * Baggage provides durable storage for vat state. */ -type Baggage = { +export type Baggage = { has: (key: string) => boolean; get: (key: string) => unknown; init: (key: string, value: unknown) => void; From 0f20610cab0fdba8b222947aabe10459811b2b2f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:02:46 -0800 Subject: [PATCH 04/47] feat(kernel-browser-runtime): Add reset method to KernelFacade Expose the kernel's reset method via CapTP so it can be called from the background script. Co-Authored-By: Claude Opus 4.5 --- .../src/kernel-worker/captp/kernel-facade.ts | 4 ++++ packages/kernel-browser-runtime/src/types.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts index f4cf12d84..40d060422 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -50,6 +50,10 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { } return { kref: rootKref }; }, + + reset: async () => { + return kernel.reset(); + }, }); } harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index bd48cd07e..90591f30b 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -30,4 +30,5 @@ export type KernelFacade = { pingVat: Kernel['pingVat']; getVatRoot: (krefString: string) => Promise; getSystemVatRoot: (name: string) => Promise<{ kref: string }>; + reset: Kernel['reset']; }; From 2e9f67b02b706a194588ce57c833f65e9645f2de Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:11:41 -0800 Subject: [PATCH 05/47] refactor(omnium-gatherum): Rename bootstrap-vat to controller-vat The vat hosts controllers, which better describes its purpose than the generic "bootstrap" name. Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/package.json | 2 +- packages/omnium-gatherum/src/offscreen.ts | 2 +- .../src/vats/{bootstrap-vat.ts => controller-vat.ts} | 2 +- packages/omnium-gatherum/vite.config.ts | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) rename packages/omnium-gatherum/src/vats/{bootstrap-vat.ts => controller-vat.ts} (98%) diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index cbcd17d95..1fc6b6d62 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -20,7 +20,7 @@ "build:dev": "yarn build:vite --mode development", "build:watch": "yarn build:dev --watch", "build:browser": "OPEN_BROWSER=true yarn build:dev --watch", - "build:caplets": "ocap bundle src/caplets/echo && ocap bundle src/vats/bootstrap-vat.ts", + "build:caplets": "ocap bundle src/caplets/echo && ocap bundle src/vats/controller-vat.ts", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/omnium-gatherum", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 8339e8ed3..f3c3b335f 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -60,7 +60,7 @@ async function makeKernelWorker(): Promise< const systemVats = [ { name: 'omnium-bootstrap', - bundleSpec: chrome.runtime.getURL('bootstrap-vat-bundle.json'), + bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), services: ['kernelFacet'], }, ]; diff --git a/packages/omnium-gatherum/src/vats/bootstrap-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts similarity index 98% rename from packages/omnium-gatherum/src/vats/bootstrap-vat.ts rename to packages/omnium-gatherum/src/vats/controller-vat.ts index 525bdc8d4..1fbb0a85c 100644 --- a/packages/omnium-gatherum/src/vats/bootstrap-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -75,7 +75,7 @@ export function buildRootObject( 'getCapletRoot', ]); - return makeDefaultExo('omnium-bootstrap', { + return makeDefaultExo('omnium-controllers', { /** * Initialize the bootstrap vat with services from the kernel. * diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index fe3c1da72..97c8fdd3f 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,11 +38,11 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', - // Bootstrap vat bundle (system vat for kernel services) + // Controller vat bundle (system vat for kernel services) { - src: 'packages/omnium-gatherum/src/vats/bootstrap-vat.bundle', + src: 'packages/omnium-gatherum/src/vats/controller-vat.bundle', dest: './', - rename: 'bootstrap-vat-bundle.json', + rename: 'controller-vat-bundle.json', }, // Caplets (add new caplet entries here) { From 8e086d09a337f670e86730c4560ab89c5b18e0aa Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:19:38 -0800 Subject: [PATCH 06/47] fix(ocap-kernel): Avoid deadlock in E(kernelFacet) calls from within cranks Changed invokeKernelService to not await the service method result. Instead, it uses Promise chaining to resolve the kernel promise when the method eventually completes. This allows service methods to internally use waitForCrank() without causing deadlock - the crank can complete, and the resolution happens in a future turn of the event loop. Key changes: - KernelServiceManager.invokeKernelService() now returns void instead of Promise and uses Promise.resolve().then().catch() for async handling - KernelRouter.#deliverKernelServiceMessage() is now synchronous - Updated tests to use delay() for microtask flushing Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/KernelRouter.ts | 18 +++----- .../src/KernelServiceManager.test.ts | 31 ++++++++----- .../ocap-kernel/src/KernelServiceManager.ts | 46 +++++++++++++++---- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/ocap-kernel/src/KernelRouter.ts b/packages/ocap-kernel/src/KernelRouter.ts index d26b82ea1..9ebdeb9e2 100644 --- a/packages/ocap-kernel/src/KernelRouter.ts +++ b/packages/ocap-kernel/src/KernelRouter.ts @@ -45,10 +45,7 @@ export class KernelRouter { readonly #getEndpoint: (endpointId: EndpointId) => EndpointHandle; /** A function that invokes a method on a kernel service. */ - readonly #invokeKernelService: ( - target: KRef, - message: Message, - ) => Promise; + readonly #invokeKernelService: (target: KRef, message: Message) => void; /** The logger, if any. */ readonly #logger: Logger | undefined; @@ -66,7 +63,7 @@ export class KernelRouter { kernelStore: KernelStore, kernelQueue: KernelQueue, getEndpoint: (endpointId: EndpointId) => EndpointHandle, - invokeKernelService: (target: KRef, message: Message) => Promise, + invokeKernelService: (target: KRef, message: Message) => void, logger?: Logger, ) { this.#kernelStore = kernelStore; @@ -266,7 +263,7 @@ export class KernelRouter { // Continue processing other messages - don't let one failure crash the queue } } else if (isKernelServiceMessage) { - crankResults = await this.#deliverKernelServiceMessage(target, message); + crankResults = this.#deliverKernelServiceMessage(target, message); } else { Fail`no owner for kernel object ${target}`; } @@ -286,13 +283,10 @@ export class KernelRouter { * * @param target - The kernel reference of the target service object. * @param message - The message to deliver to the service. - * @returns A promise that resolves to the crank results indicating the delivery was to the kernel. + * @returns The crank results indicating the delivery was to the kernel. */ - async #deliverKernelServiceMessage( - target: KRef, - message: Message, - ): Promise { - await this.#invokeKernelService(target, message); + #deliverKernelServiceMessage(target: KRef, message: Message): CrankResults { + this.#invokeKernelService(target, message); return { didDelivery: 'kernel' }; } diff --git a/packages/ocap-kernel/src/KernelServiceManager.test.ts b/packages/ocap-kernel/src/KernelServiceManager.test.ts index a02ac6944..eacee0d31 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.test.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.test.ts @@ -1,3 +1,4 @@ +import { delay } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -184,7 +185,8 @@ describe('KernelServiceManager', () => { methargs: kser(['testMethod', ['arg1', 'arg2']]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(testMethod).toHaveBeenCalledWith('arg1', 'arg2'); expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); @@ -206,7 +208,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(testMethod).toHaveBeenCalledWith('arg1'); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ @@ -231,7 +234,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(testError)], @@ -255,7 +259,8 @@ describe('KernelServiceManager', () => { methargs: kser(['testMethod', []]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(loggerErrorSpy).toHaveBeenCalledWith( 'Error in kernel service method:', @@ -264,14 +269,14 @@ describe('KernelServiceManager', () => { expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); }); - it('throws error for non-existent service', async () => { + it('throws error for non-existent service', () => { const message: Message = { methargs: kser(['testMethod', []]), }; - await expect( + expect(() => serviceManager.invokeKernelService('ko999', message), - ).rejects.toThrow('No registered service for ko999'); + ).toThrow('No registered service for ko999'); }); it('handles unknown method with result', async () => { @@ -289,7 +294,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(Error("unknown service method 'unknownMethod'"))], @@ -311,7 +317,8 @@ describe('KernelServiceManager', () => { methargs: kser(['unknownMethod', []]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(loggerErrorSpy).toHaveBeenCalledWith( "unknown service method 'unknownMethod'", @@ -332,7 +339,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(Error("unknown service method 'anyMethod'"))], @@ -354,7 +362,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', false, kser(undefined)], diff --git a/packages/ocap-kernel/src/KernelServiceManager.ts b/packages/ocap-kernel/src/KernelServiceManager.ts index 30be080dc..4741f6255 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.ts @@ -112,10 +112,16 @@ export class KernelServiceManager { /** * Invoke a kernel service. * + * This method does NOT await the service method result. Instead, it uses + * promise chaining to resolve the kernel promise when the method eventually + * completes. This allows service methods to use `waitForCrank()` without + * causing deadlock - the crank can complete, and the resolution happens + * in a future turn of the event loop. + * * @param target - The target kref of the service. * @param message - The message to invoke the service with. */ - async invokeKernelService(target: KRef, message: Message): Promise { + invokeKernelService(target: KRef, message: Message): void { const kernelService = this.#kernelServicesByObject.get(target); if (!kernelService) { throw Error(`No registered service for ${target}`); @@ -141,20 +147,40 @@ export class KernelServiceManager { } assert.typeof(methodFunction, 'function'); assert(Array.isArray(args)); + + // Call the method without awaiting. This allows the crank to complete + // even if the method internally waits for the crank to end. try { - const resultValue = await methodFunction.apply(service, args); - if (result) { - this.#kernelQueue.resolvePromises('kernel', [ - [result, false, kser(resultValue)], - ]); - } - } catch (problem) { + const maybePromise = methodFunction.apply(service, args); + // Use Promise.resolve to normalize: if maybePromise is a Promise, it + // returns that Promise; if it's a value, it returns an immediately- + // resolved Promise. + Promise.resolve(maybePromise) + .then((resultValue) => { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, false, kser(resultValue)], + ]); + } + return undefined; + }) + .catch((problem: unknown) => { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, true, kser(problem)], + ]); + } else { + this.#logger?.error('Error in kernel service method:', problem); + } + }); + } catch (syncError) { + // Handle synchronous errors thrown before returning a Promise if (result) { this.#kernelQueue.resolvePromises('kernel', [ - [result, true, kser(problem)], + [result, true, kser(syncError)], ]); } else { - this.#logger?.error('Error in kernel service method:', problem); + this.#logger?.error('Error in kernel service method:', syncError); } } } From 54e9e992cd92b39dfe4a59cc9c85f07636f4c131 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:02:03 -0800 Subject: [PATCH 07/47] feat(ocap-kernel): Add globals config for vat compartment endowments Add a `globals` field to VatConfig that allows specifying which globals should be available in the vat's SES Compartment. This fixes the `Date.now()` error when the controller-vat runs under SES lockdown. VatSupervisor reads the globals list and adds requested globals from an allowlist to the compartment endowments. Currently only `Date` is allowed. Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/types.test.ts | 48 +++++++++++++++++++ packages/ocap-kernel/src/types.ts | 6 ++- .../ocap-kernel/src/vats/VatSupervisor.ts | 20 +++++++- packages/omnium-gatherum/src/offscreen.ts | 1 + 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/ocap-kernel/src/types.test.ts b/packages/ocap-kernel/src/types.test.ts index 64e1c284d..d29df4ed9 100644 --- a/packages/ocap-kernel/src/types.test.ts +++ b/packages/ocap-kernel/src/types.test.ts @@ -153,6 +153,54 @@ describe('isVatConfig', () => { ])('rejects configs with $name', ({ config }) => { expect(isVatConfig(config)).toBe(false); }); + + it.each([ + { + name: 'with valid globals array', + config: { + bundleSpec: 'bundle.js', + globals: ['Date'], + }, + expected: true, + }, + { + name: 'with empty globals array', + config: { + bundleSpec: 'bundle.js', + globals: [], + }, + expected: true, + }, + { + name: 'with multiple globals', + config: { + bundleSpec: 'bundle.js', + globals: ['Date', 'Math'], + }, + expected: true, + }, + ])('validates $name', ({ config, expected }) => { + expect(isVatConfig(config)).toBe(expected); + }); + + it.each([ + { + name: 'non-array globals', + config: { + bundleSpec: 'bundle.js', + globals: 'Date', + }, + }, + { + name: 'globals array with non-string element', + config: { + bundleSpec: 'bundle.js', + globals: ['Date', 123], + }, + }, + ])('rejects configs with $name', ({ config }) => { + expect(isVatConfig(config)).toBe(false); + }); }); describe('insistMessage', () => { diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 6c01e40ff..fc6a90b35 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -375,6 +375,7 @@ export type VatConfig = UserCodeSpec & { creationOptions?: Record; parameters?: Record; platformConfig?: Partial; + globals?: string[]; }; const UserCodeSpecStruct = union([ @@ -396,14 +397,15 @@ export const VatConfigStruct = define('VatConfig', (value) => { return false; } - const { creationOptions, parameters, platformConfig, ...specOnly } = + const { creationOptions, parameters, platformConfig, globals, ...specOnly } = value as Record; return ( is(specOnly, UserCodeSpecStruct) && (!creationOptions || is(creationOptions, UnsafeJsonStruct)) && (!parameters || is(parameters, UnsafeJsonStruct)) && - (!platformConfig || is(platformConfig, platformConfigStruct)) + (!platformConfig || is(platformConfig, platformConfigStruct)) && + (!globals || is(globals, array(string()))) ); }); diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index 48058a066..e935e5490 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -293,13 +293,29 @@ export class VatSupervisor { meterControl: makeDummyMeterControl(), }); + const { bundleSpec, parameters, platformConfig, globals } = vatConfig; + + // Map of allowed global names to their values + const allowedGlobals: Record = { + Date: globalThis.Date, + }; + + // Build additional endowments from globals list + const requestedGlobals: Record = {}; + if (globals) { + for (const name of globals) { + if (name in allowedGlobals) { + requestedGlobals[name] = allowedGlobals[name]; + } + } + } + const workerEndowments = { console: this.#logger.subLogger({ tags: ['console'] }), assert: globalThis.assert, + ...requestedGlobals, }; - const { bundleSpec, parameters, platformConfig } = vatConfig; - const platformEndowments = platformConfig ? await this.#makePlatform(platformConfig, this.#platformOptions) : {}; diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index f3c3b335f..2f1e3ae63 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -62,6 +62,7 @@ async function makeKernelWorker(): Promise< name: 'omnium-bootstrap', bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), services: ['kernelFacet'], + globals: ['Date'], }, ]; workerUrlParams.set('system-vats', JSON.stringify(systemVats)); From 7a9c06c7d490f0a5089e24dfe3070be3b469b5b1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:02:34 -0800 Subject: [PATCH 08/47] feat(omnium-gatherum): Add callCapletMethod API and simplify echo caplet Add `callCapletMethod` to the omnium.caplet API for invoking methods on installed caplets directly from the console. Simplify the echo caplet ID from 'com.example.echo' to 'echo' and update the response format. Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/README.md | 8 +++----- packages/omnium-gatherum/src/background.ts | 19 +++++++++++++++++-- .../src/caplets/echo/echo-caplet.js | 2 +- .../src/caplets/echo/manifest.json | 2 +- .../test/caplet-integration.test.ts | 17 ++++++++--------- .../test/fixtures/manifests.ts | 2 +- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index dc4449030..6e4073c3b 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -23,11 +23,9 @@ const { manifest } = await omnium.caplet.load('echo'); // 2. Install the caplet await omnium.caplet.install(manifest); -// 3. List installed caplets -await omnium.caplet.list(); - -// 4. Get a specific caplet -await omnium.caplet.get('echo'); +// 3. Call a method on the caplet +await omnium.caplet.callCapletMethod('echo', 'echo', ['Hello, world!']); +// echo: Hello, world! ``` ## Contributing diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 176d6c9e5..bb9e40118 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -237,6 +237,14 @@ function defineGlobals(): GlobalSetters { return { manifest, bundle }; }; + const getCapletRoot = async (capletId: string): Promise => { + const { slots } = await callBootstrap<{ slots: [string] }>( + 'getCapletRoot', + [capletId], + ); + return slots[0]; // This is the caplet's root kref + }; + Object.defineProperties(globalThis.omnium, { caplet: { value: harden({ @@ -248,8 +256,15 @@ function defineGlobals(): GlobalSetters { load: loadCaplet, get: async (capletId: string) => callBootstrap('get', [capletId]), - getCapletRoot: async (capletId: string) => - callBootstrap('getCapletRoot', [capletId]), + getCapletRoot, + callCapletMethod: async ( + capletId: string, + method: string, + args: unknown[], + ) => { + const rootKref = await getCapletRoot(capletId); + return await E(kernel).queueMessage(rootKref, method, args); + }, }), }, }); diff --git a/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js index b32a80311..c0d0ee31c 100644 --- a/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js +++ b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js @@ -48,7 +48,7 @@ export function buildRootObject(_vatPowers, _parameters, _baggage) { */ echo(message) { log('Echoing message:', message); - return `Echo: ${message}`; + return `echo: ${message}`; }, }); } diff --git a/packages/omnium-gatherum/src/caplets/echo/manifest.json b/packages/omnium-gatherum/src/caplets/echo/manifest.json index 5a31c4f12..2be391e7d 100644 --- a/packages/omnium-gatherum/src/caplets/echo/manifest.json +++ b/packages/omnium-gatherum/src/caplets/echo/manifest.json @@ -1,5 +1,5 @@ { - "id": "com.example.echo", + "id": "echo", "name": "Echo Caplet", "version": "1.0.0", "bundleSpec": "echo-caplet.bundle" diff --git a/packages/omnium-gatherum/test/caplet-integration.test.ts b/packages/omnium-gatherum/test/caplet-integration.test.ts index d4c591557..13a9d5f7c 100644 --- a/packages/omnium-gatherum/test/caplet-integration.test.ts +++ b/packages/omnium-gatherum/test/caplet-integration.test.ts @@ -68,18 +68,18 @@ describe('Caplet Integration - Echo Caplet', () => { it('installs a caplet', async () => { const result = await capletController.install(echoCapletManifest); - expect(result.capletId).toBe('com.example.echo'); + expect(result.capletId).toBe('echo'); expect(result.subclusterId).toBe('test-subcluster-1'); }); it('retrieves installed a caplet', async () => { await capletController.install(echoCapletManifest); - const caplet = capletController.get('com.example.echo'); + const caplet = capletController.get('echo'); expect(caplet).toStrictEqual({ manifest: { - id: 'com.example.echo', + id: 'echo', name: 'Echo Caplet', version: '1.0.0', bundleSpec: expect.anything(), @@ -98,7 +98,7 @@ describe('Caplet Integration - Echo Caplet', () => { const list = capletController.list(); expect(list).toHaveLength(1); - expect(list[0]?.manifest.id).toBe('com.example.echo'); + expect(list[0]?.manifest.id).toBe('echo'); }); it('uninstalls a caplet', async () => { @@ -107,12 +107,12 @@ describe('Caplet Integration - Echo Caplet', () => { let list = capletController.list(); expect(list).toHaveLength(1); - await capletController.uninstall('com.example.echo'); + await capletController.uninstall('echo'); list = capletController.list(); expect(list).toHaveLength(0); - const caplet = capletController.get('com.example.echo'); + const caplet = capletController.get('echo'); expect(caplet).toBeUndefined(); }); @@ -133,8 +133,7 @@ describe('Caplet Integration - Echo Caplet', () => { it('gets caplet root object', async () => { await capletController.install(echoCapletManifest); - const rootPresence = - await capletController.getCapletRoot('com.example.echo'); + const rootPresence = await capletController.getCapletRoot('echo'); expect(rootPresence).toStrictEqual({ kref: 'ko1' }); }); @@ -169,6 +168,6 @@ describe('Caplet Integration - Echo Caplet', () => { // The caplet should still be there const list = newController.list(); expect(list).toHaveLength(1); - expect(list[0]?.manifest.id).toBe('com.example.echo'); + expect(list[0]?.manifest.id).toBe('echo'); }); }); diff --git a/packages/omnium-gatherum/test/fixtures/manifests.ts b/packages/omnium-gatherum/test/fixtures/manifests.ts index 538104057..26ea53225 100644 --- a/packages/omnium-gatherum/test/fixtures/manifests.ts +++ b/packages/omnium-gatherum/test/fixtures/manifests.ts @@ -19,7 +19,7 @@ const ECHO_CAPLET_DIR = path.join( * to an absolute file:// URL for tests. * * This Caplet provides a simple "echo" service that returns - * the input message with an "Echo: " prefix. + * the input message with an "echo: " prefix. * * Usage: * - Provides: "echo" service From 161d5deb1cdd93ac39fe10b03f015517ff7a29af Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:14:46 -0800 Subject: [PATCH 09/47] chore(omnium-gatherum): Remove dead code from controller migration Remove `initializeControllers` and `makeChromeStorageAdapter` which are no longer used now that the caplet controller runs inside the vat with baggage-backed storage instead of chrome.storage. Co-Authored-By: Claude Opus 4.5 --- .../omnium-gatherum/src/controllers/index.ts | 69 +------------- .../storage/chrome-storage.test.ts | 95 ------------------- .../src/controllers/storage/chrome-storage.ts | 48 ---------- .../src/controllers/storage/index.ts | 1 - 4 files changed, 1 insertion(+), 212 deletions(-) delete mode 100644 packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts delete mode 100644 packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index e31664d41..2f5b78ade 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -1,12 +1,3 @@ -import { E } from '@endo/eventual-send'; -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; -import type { Logger } from '@metamask/logger'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; - -import { CapletController } from './caplet/caplet-controller.ts'; -import type { CapletControllerFacet, LaunchResult } from './caplet/index.ts'; -import { makeChromeStorageAdapter } from './storage/index.ts'; - // Base controller export { Controller } from './base-controller.ts'; export type { ControllerConfig, ControllerMethods, FacetOf } from './types.ts'; @@ -16,10 +7,7 @@ export type { StorageAdapter, ControllerStorageConfig, } from './storage/index.ts'; -export { - makeChromeStorageAdapter, - ControllerStorage, -} from './storage/index.ts'; +export { ControllerStorage } from './storage/index.ts'; // Caplet export type { @@ -43,58 +31,3 @@ export { CapletManifestStruct, CapletController, } from './caplet/index.ts'; - -type InitializeControllersOptions = { - logger: Logger; - kernel: KernelFacade | Promise; -}; - -/** - * Initializes the controllers for the host application. - * - * @param options - The options for initializing the controllers. - * @param options.logger - The logger to use. - * @param options.kernel - The kernel to use. - * @returns The controllers for the host application. - */ -export async function initializeControllers({ - logger, - kernel, -}: InitializeControllersOptions): Promise<{ - caplet: CapletControllerFacet; -}> { - const storageAdapter = makeChromeStorageAdapter(); - - const capletController = await CapletController.make( - { logger: logger.subLogger({ tags: ['caplet'] }) }, - { - adapter: storageAdapter, - /** - * Launch a subcluster for a caplet. Not concurrency safe. - * - * @param config - The configuration for the subcluster. - * @returns The subcluster ID. - */ - launchSubcluster: async ( - config: ClusterConfig, - ): Promise => { - const result = await E(kernel).launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootKref: result.rootKref, - }; - }, - terminateSubcluster: async (subclusterId: string): Promise => { - await E(kernel).terminateSubcluster(subclusterId); - }, - getVatRoot: async (krefString: string): Promise => { - // Convert kref string to presence via kernel facade - return E(kernel).getVatRoot(krefString); - }, - }, - ); - - return { - caplet: capletController, - }; -} diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts deleted file mode 100644 index 63639584b..000000000 --- a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeChromeStorageAdapter } from './chrome-storage.ts'; - -describe('makeChromeStorageAdapter', () => { - const mockStorage = { - get: vi.fn().mockResolvedValue({}), - set: vi.fn(), - remove: vi.fn(), - }; - - beforeEach(() => { - mockStorage.get.mockResolvedValue({}); - }); - - describe('get', () => { - it('returns value for existing key', async () => { - mockStorage.get.mockResolvedValue({ testKey: 'testValue' }); - - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - const result = await adapter.get('testKey'); - - expect(result).toBe('testValue'); - expect(mockStorage.get).toHaveBeenCalledWith('testKey'); - }); - - it('returns undefined for non-existent key', async () => { - mockStorage.get.mockResolvedValue({}); - - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - const result = await adapter.get('nonExistent'); - - expect(result).toBeUndefined(); - }); - }); - - describe('set', () => { - it('sets a value', async () => { - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - await adapter.set('key', 'value'); - - expect(mockStorage.set).toHaveBeenCalledWith({ key: 'value' }); - }); - }); - - describe('delete', () => { - it('deletes a key', async () => { - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - await adapter.delete('keyToDelete'); - - expect(mockStorage.remove).toHaveBeenCalledWith('keyToDelete'); - }); - }); - - describe('keys', () => { - it('returns all keys when no prefix provided', async () => { - mockStorage.get.mockResolvedValue({ - key1: 'value1', - key2: 'value2', - other: 'value3', - }); - - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - const result = await adapter.keys(); - - expect(result).toStrictEqual(['key1', 'key2', 'other']); - expect(mockStorage.get).toHaveBeenCalledWith(null); - }); - - it('filters keys by prefix', async () => { - mockStorage.get.mockResolvedValue({ - 'prefix.key1': 'value1', - 'prefix.key2': 'value2', - other: 'value3', - }); - - const adapter = makeChromeStorageAdapter( - mockStorage as unknown as chrome.storage.StorageArea, - ); - const result = await adapter.keys('prefix.'); - - expect(result).toStrictEqual(['prefix.key1', 'prefix.key2']); - }); - }); -}); diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts deleted file mode 100644 index 64ee7ec16..000000000 --- a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Json } from '@metamask/utils'; - -import type { StorageAdapter } from './types.ts'; - -/** - * Create a storage adapter backed by Chrome Storage API. - * - * @param storage - The Chrome storage area to use (defaults to chrome.storage.local). - * @returns A hardened StorageAdapter instance. - */ -export function makeChromeStorageAdapter( - storage: chrome.storage.StorageArea = chrome.storage.local, -): StorageAdapter { - return harden({ - async get(key: string): Promise { - const result = await storage.get(key); - return result[key] as Value | undefined; - }, - - async set(key: string, value: Json): Promise { - await storage.set({ [key]: value }); - }, - - async delete(key: string): Promise { - await storage.remove(key); - }, - - /** - * Get all keys, optionally filtered by prefix. - * - * Note: This loads all storage data into memory to enumerate keys, - * as Chrome Storage API doesn't provide a native keys-only method. - * May be inefficient for large storage. - * - * @param prefix - Optional prefix to filter keys by. - * @returns Array of matching key names. - */ - async keys(prefix?: string): Promise { - const all = await storage.get(null); - const allKeys = Object.keys(all); - if (prefix === undefined) { - return allKeys; - } - return allKeys.filter((k) => k.startsWith(prefix)); - }, - }); -} -harden(makeChromeStorageAdapter); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts index 817055c98..13c905cf3 100644 --- a/packages/omnium-gatherum/src/controllers/storage/index.ts +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -1,4 +1,3 @@ export type { StorageAdapter } from './types.ts'; export type { ControllerStorageConfig } from './controller-storage.ts'; -export { makeChromeStorageAdapter } from './chrome-storage.ts'; export { ControllerStorage } from './controller-storage.ts'; From 3ead1522453462266245cc0b35ec3848daf472af Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:22:48 -0800 Subject: [PATCH 10/47] fix(omnium-gatherum): Fix callBootstrap type annotations Use proper return type from KernelFacade['queueMessage'] instead of generic type parameter. Add error handling for missing root kref. Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/src/background.ts | 36 +++++++++------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index bb9e40118..cca245a1a 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -14,11 +14,7 @@ import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import type { - CapletManifest, - InstalledCaplet, - InstallResult, -} from './controllers/index.ts'; +import type { CapletManifest } from './controllers/index.ts'; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -161,10 +157,10 @@ function defineGlobals(): GlobalSetters { * @param args - Arguments to pass to the method. * @returns The result from the bootstrap vat. */ - const callBootstrap = async ( + const callBootstrap = async ( method: string, args: unknown[] = [], - ): Promise => { + ): ReturnType => { if (!kernel) { throw new Error('Kernel facade not initialized'); } @@ -172,11 +168,7 @@ function defineGlobals(): GlobalSetters { throw new Error('Bootstrap vat not initialized'); } - const capData = await E(kernel).queueMessage(bootstrapKref, method, args); - // CapData body is JSON-stringified; parse it to get the actual value - // return JSON.parse(capData.body) as T; - // @ts-expect-error - CapData is not assignable to T - return capData; + return await E(kernel).queueMessage(bootstrapKref, method, args); }; Object.defineProperty(globalThis, 'E', { @@ -238,24 +230,24 @@ function defineGlobals(): GlobalSetters { }; const getCapletRoot = async (capletId: string): Promise => { - const { slots } = await callBootstrap<{ slots: [string] }>( - 'getCapletRoot', - [capletId], - ); - return slots[0]; // This is the caplet's root kref + const { slots } = await callBootstrap('getCapletRoot', [capletId]); + const rootKref = slots[0]; + if (!rootKref) { + throw new Error(`Caplet "${capletId}" has no root kref`); + } + return rootKref; }; Object.defineProperties(globalThis.omnium, { caplet: { value: harden({ install: async (manifest: CapletManifest) => - callBootstrap('install', [manifest]), + callBootstrap('install', [manifest]), uninstall: async (capletId: string) => - callBootstrap('uninstall', [capletId]), - list: async () => callBootstrap('list'), + callBootstrap('uninstall', [capletId]), + list: async () => callBootstrap('list'), load: loadCaplet, - get: async (capletId: string) => - callBootstrap('get', [capletId]), + get: async (capletId: string) => callBootstrap('get', [capletId]), getCapletRoot, callCapletMethod: async ( capletId: string, From 35737d97cca2e634cf0da6a01519f5755000b6b4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:31:23 -0800 Subject: [PATCH 11/47] fix(omnium-gatherum): Address review feedback - Fix callBootstrap type annotations to use proper return type - Handle null tombstones in baggage adapter get() method - Add promise rejection handling to controller-vat bootstrap() Co-Authored-By: Claude Opus 4.5 --- .../src/vats/controller-vat.ts | 61 ++++++++++--------- .../src/vats/storage/baggage-adapter.test.ts | 13 ++++ .../src/vats/storage/baggage-adapter.ts | 7 ++- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 1fbb0a85c..c9dc6d8a1 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -59,12 +59,12 @@ export function buildRootObject( // Create baggage-backed storage adapter const storageAdapter = makeBaggageStorageAdapter(baggage); - // Promise kit for the caplet controller facet, resolved in bootstrap() + // Promise kit for the caplet controller facet, resolved/rejected in bootstrap() const { promise: capletFacetP, resolve: resolveCapletFacet, - }: PromiseKit = - makePromiseKit(); + reject: rejectCapletFacet, + }: PromiseKit = makePromiseKit(); // Define delegating methods for caplet operations const capletMethods = defineMethods(capletFacetP, [ @@ -88,34 +88,39 @@ export function buildRootObject( ): Promise { logger?.info('Bootstrap called'); - const { kernelFacet } = services; - if (!kernelFacet) { - throw new Error('kernelFacet service is required'); - } + try { + const { kernelFacet } = services; + if (!kernelFacet) { + throw new Error('kernelFacet service is required'); + } - // Initialize caplet controller with baggage-backed storage - const capletFacet = await CapletController.make( - { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, - { - adapter: storageAdapter, - launchSubcluster: async ( - config: ClusterConfig, - ): Promise => { - const result = await E(kernelFacet).launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootKref: result.rootKref, - }; + // Initialize caplet controller with baggage-backed storage + const capletFacet = await CapletController.make( + { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + const result = await E(kernelFacet).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; + }, + terminateSubcluster: async (subclusterId: string): Promise => + E(kernelFacet).terminateSubcluster(subclusterId), + getVatRoot: async (krefString: string): Promise => + E(kernelFacet).getVatRoot(krefString), }, - terminateSubcluster: async (subclusterId: string): Promise => - E(kernelFacet).terminateSubcluster(subclusterId), - getVatRoot: async (krefString: string): Promise => - E(kernelFacet).getVatRoot(krefString), - }, - ); - resolveCapletFacet(capletFacet); + ); + resolveCapletFacet(capletFacet); - logger?.info('Bootstrap complete'); + logger?.info('Bootstrap complete'); + } catch (error) { + rejectCapletFacet(error); + throw error; + } }, ...capletMethods, diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts index 306a2951a..ce1f04e86 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -48,6 +48,19 @@ describe('makeBaggageStorageAdapter', () => { const result = await adapter.get('test-key'); expect(result).toStrictEqual({ foo: 'bar' }); }); + + it('returns undefined for deleted key (null tombstone)', async () => { + await adapter.set('to-delete', { data: 'test' }); + await adapter.delete('to-delete'); + + // Baggage still has the key but value is null + expect(baggage._store.has('to-delete')).toBe(true); + expect(baggage._store.get('to-delete')).toBeNull(); + + // Adapter should return undefined, not null + const result = await adapter.get('to-delete'); + expect(result).toBeUndefined(); + }); }); describe('set', () => { diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts index fbd203f84..20e0469a8 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -55,7 +55,12 @@ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { return harden({ async get(key: string): Promise { if (baggage.has(key)) { - return baggage.get(key) as Value; + const value = baggage.get(key); + // Return undefined for null tombstones (deleted keys) + if (value === null) { + return undefined; + } + return value as Value; } return undefined; }, From f43165994768c164d1b36489f26e36315e4c1958 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:08:37 -0800 Subject: [PATCH 12/47] fix(omnium-gatherum): Add console forwarding from kernel-worker and vats Add console forwarding to omnium-gatherum to match the extension implementation. This prevents "Unexpected message" errors in the background script when receiving console-forward messages, and ensures console output from the offscreen document and vat iframes is visible in the background devtools console. Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/src/background.ts | 6 +++++- packages/omnium-gatherum/src/offscreen.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index cca245a1a..f7960ae8b 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -4,6 +4,8 @@ import { makeCapTPNotification, isCapTPNotification, getCapTPMessage, + isConsoleForwardMessage, + handleConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; import type { CapTPMessage, @@ -121,7 +123,9 @@ async function main(): Promise { try { await offscreenStream.drain((message) => { - if (isCapTPNotification(message)) { + if (isConsoleForwardMessage(message)) { + handleConsoleForwardMessage(message); + } else if (isCapTPNotification(message)) { const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); } else { diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 2f1e3ae63..0f89d6721 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -2,6 +2,8 @@ import { makeIframeVatWorker, PlatformServicesServer, createRelayQueryString, + setupConsoleForwarding, + isConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; @@ -31,6 +33,20 @@ async function main(): Promise { JsonRpcMessage >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); + setupConsoleForwarding({ + source: 'offscreen', + onMessage: (message) => { + backgroundStream.write(message).catch(() => undefined); + }, + }); + + // Listen for console messages from vat iframes and forward to background + window.addEventListener('message', (event) => { + if (isConsoleForwardMessage(event.data)) { + backgroundStream.write(event.data).catch(() => undefined); + } + }); + const kernelStream = await makeKernelWorker(); // Handle messages from the background script / kernel From 2f3d086a1d241e183285985e3545e1f3869caf77 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:11:14 -0800 Subject: [PATCH 13/47] refactor(omnium): Rename bootstrap vat to controller vat --- packages/omnium-gatherum/src/background.ts | 41 ++++++++++--------- packages/omnium-gatherum/src/offscreen.ts | 2 +- .../src/vats/controller-vat.ts | 8 ++-- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index f7960ae8b..0db4d3912 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -109,16 +109,16 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globals.setKernel(kernelP); - // Set up bootstrap vat initialization (runs concurrently with stream drain) + // Set up controller vat initialization (runs concurrently with stream drain) E(kernelP) - .getSystemVatRoot('omnium-bootstrap') + .getSystemVatRoot('omnium-controllers') .then(({ kref }) => { - globals.setBootstrapKref(kref); - logger.info('Bootstrap vat initialized'); + globals.setControllerVatKref(kref); + logger.info('Controller vat initialized'); return undefined; }) .catch((error) => { - logger.error('Failed to initialize bootstrap vat:', error); + logger.error('Failed to initialize controller vat:', error); }); try { @@ -143,7 +143,8 @@ async function main(): Promise { type GlobalSetters = { setKernel: (kernel: KernelFacade | Promise) => void; - setBootstrapKref: (kref: string) => void; + // Not actually globally available + setControllerVatKref: (kref: string) => void; }; /** @@ -152,27 +153,27 @@ type GlobalSetters = { * @returns A device for setting the global values. */ function defineGlobals(): GlobalSetters { - let bootstrapKref: string; + let controllerVatKref: string; /** - * Call a method on the bootstrap vat via queueMessage. + * Call a method on the controller vat via queueMessage. * * @param method - The method name to call. * @param args - Arguments to pass to the method. - * @returns The result from the bootstrap vat. + * @returns The result from the controller vat. */ - const callBootstrap = async ( + const callController = async ( method: string, args: unknown[] = [], ): ReturnType => { if (!kernel) { throw new Error('Kernel facade not initialized'); } - if (!bootstrapKref) { - throw new Error('Bootstrap vat not initialized'); + if (!controllerVatKref) { + throw new Error('Controller vat not initialized'); } - return await E(kernel).queueMessage(bootstrapKref, method, args); + return await E(kernel).queueMessage(controllerVatKref, method, args); }; Object.defineProperty(globalThis, 'E', { @@ -234,7 +235,7 @@ function defineGlobals(): GlobalSetters { }; const getCapletRoot = async (capletId: string): Promise => { - const { slots } = await callBootstrap('getCapletRoot', [capletId]); + const { slots } = await callController('getCapletRoot', [capletId]); const rootKref = slots[0]; if (!rootKref) { throw new Error(`Caplet "${capletId}" has no root kref`); @@ -246,12 +247,12 @@ function defineGlobals(): GlobalSetters { caplet: { value: harden({ install: async (manifest: CapletManifest) => - callBootstrap('install', [manifest]), + callController('install', [manifest]), uninstall: async (capletId: string) => - callBootstrap('uninstall', [capletId]), - list: async () => callBootstrap('list'), + callController('uninstall', [capletId]), + list: async () => callController('list'), load: loadCaplet, - get: async (capletId: string) => callBootstrap('get', [capletId]), + get: async (capletId: string) => callController('get', [capletId]), getCapletRoot, callCapletMethod: async ( capletId: string, @@ -270,8 +271,8 @@ function defineGlobals(): GlobalSetters { setKernel: (kernel) => { globalThis.kernel = kernel; }, - setBootstrapKref: (kref) => { - bootstrapKref = kref; + setControllerVatKref: (kref) => { + controllerVatKref = kref; }, }; } diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 0f89d6721..480cf5810 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -75,7 +75,7 @@ async function makeKernelWorker(): Promise< // Configure system vats to launch at kernel initialization const systemVats = [ { - name: 'omnium-bootstrap', + name: 'omnium-controllers', bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), services: ['kernelFacet'], globals: ['Date'], diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index c9dc6d8a1..64f1b9701 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -15,7 +15,7 @@ import type { } from '../controllers/caplet/index.ts'; /** - * Vat powers provided to the bootstrap vat. + * Vat powers provided to the controller vat. */ type VatPowers = { logger?: Logger; @@ -31,14 +31,14 @@ type KernelFacet = { }; /** - * Services provided to the bootstrap vat. + * Services provided to the controller vat. */ type BootstrapServices = { kernelFacet?: KernelFacet; }; /** - * Bootstrap vat for Omnium system services. + * Controller vat for Omnium system services. * Hosts controllers with baggage-backed persistence. * * Methods are exposed directly on root (not nested) for queueMessage access. @@ -77,7 +77,7 @@ export function buildRootObject( return makeDefaultExo('omnium-controllers', { /** - * Initialize the bootstrap vat with services from the kernel. + * Initialize the controller vat with services from the kernel. * * @param _vats - Other vats in this subcluster (unused). * @param services - Services provided by the kernel. From 4feef90337ea36b69ebf4d87a39773802ab4927d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:23:37 -0800 Subject: [PATCH 14/47] test(ocap-kernel): Tweak kernel facet tests --- packages/ocap-kernel/src/kernel-facet.test.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 45c8c685d..d13524ecd 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -32,11 +32,9 @@ describe('makeKernelFacet', () => { { id: 's1', config: { bootstrap: 'test', vats: {} }, vats: {} }, ]), getStatus: vi.fn().mockResolvedValue({ - initialized: true, - cranksExecuted: 10, - cranksPending: 0, - vatCount: 2, - endpointCount: 3, + vats: [], + subclusters: [], + remoteComms: { isInitialized: false }, }), }; }); @@ -137,9 +135,7 @@ describe('makeKernelFacet', () => { }); it('returns undefined for unknown subcluster', () => { - vi.spyOn(deps, 'getSubcluster') - .mockImplementation() - .mockReturnValue(undefined); + vi.spyOn(deps, 'getSubcluster').mockImplementation(() => undefined); const facet = makeKernelFacet(deps) as { getSubcluster: (id: string) => Subcluster | undefined; }; @@ -191,11 +187,23 @@ describe('makeKernelFacet', () => { const result = await facet.getStatus(); - expect(result.initialized).toBe(true); - expect(result.cranksExecuted).toBe(10); - expect(result.cranksPending).toBe(0); - expect(result.vatCount).toBe(2); - expect(result.endpointCount).toBe(3); + expect(result).toStrictEqual({ + vats: [], + subclusters: [], + remoteComms: { isInitialized: false }, + }); + }); + }); + + describe('getVatRoot', () => { + it('returns a slot value for the given kref', () => { + const facet = makeKernelFacet(deps) as { + getVatRoot: (kref: string) => SlotValue; + }; + + const result = facet.getVatRoot('ko42'); + + expect(krefOf(result)).toBe('ko42'); }); }); }); From 23be8f7173d3762d170cd48e1050789d3f485fcc Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:37:18 -0800 Subject: [PATCH 15/47] fix(ocap-kernel,omnium-gatherum): Address PR review feedback - Fix baggage adapter to re-add deleted keys to tracking on set - Clear system vat roots map on kernel reset Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/Kernel.test.ts | 20 +++++++++++++++++++ packages/ocap-kernel/src/Kernel.ts | 1 + .../src/vats/storage/baggage-adapter.test.ts | 16 +++++++++++++++ .../src/vats/storage/baggage-adapter.ts | 3 +++ 4 files changed, 40 insertions(+) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index dc6bdadc1..b79219560 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -820,6 +820,26 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toHaveLength(0); }); + it('clears system vat roots', async () => { + const mockDb = makeMapKernelDatabase(); + const kernel = await Kernel.make(mockPlatformServices, mockDb, { + systemVats: [ + { + name: 'testSystemVat', + sourceSpec: 'system-vat.js', + }, + ], + }); + // Verify system vat root was stored + expect(kernel.getSystemVatRoot('testSystemVat')).toBeDefined(); + expect(kernel.getSystemVatRoot('testSystemVat')).toMatch(/^ko\d+$/u); + + await kernel.reset(); + + // Verify system vat roots are cleared + expect(kernel.getSystemVatRoot('testSystemVat')).toBeUndefined(); + }); + it('logs an error if resetting the kernel state fails', async () => { const mockDb = makeMapKernelDatabase(); const logger = new Logger('test'); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 6f3809f12..0fa205097 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -617,6 +617,7 @@ export class Kernel { await this.#kernelQueue.waitForCrank(); try { await this.terminateAllVats(); + this.#systemVatRoots.clear(); this.#resetKernelState(); } catch (error) { this.#logger.error('Error resetting kernel:', error); diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts index ce1f04e86..22a33e7ff 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -89,6 +89,22 @@ describe('makeBaggageStorageAdapter', () => { expect(keys).toContain('key1'); expect(keys).toContain('key2'); }); + + it('re-adds previously deleted key to tracking', async () => { + // Set initial value + await adapter.set('reused-key', { original: true }); + expect(await adapter.keys()).toContain('reused-key'); + + // Delete it (creates null tombstone but removes from tracking) + await adapter.delete('reused-key'); + expect(await adapter.keys()).not.toContain('reused-key'); + expect(baggage._store.has('reused-key')).toBe(true); // tombstone exists + + // Set again - should re-add to tracking + await adapter.set('reused-key', { restored: true }); + expect(await adapter.keys()).toContain('reused-key'); + expect(await adapter.get('reused-key')).toStrictEqual({ restored: true }); + }); }); describe('delete', () => { diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts index 20e0469a8..361fe5649 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -71,6 +71,9 @@ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { baggage.set(key, harden(value)); } else { baggage.init(key, harden(value)); + } + // Always ensure key is tracked (handles re-adding after delete) + if (!keys.has(key)) { keys.add(key); saveKeys(keys); } From 63eaac4e1376c9b2f050c93775f6b070d0982cf0 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:01:43 -0800 Subject: [PATCH 16/47] test(nodejs): Add system vat e2e tests --- packages/nodejs/test/e2e/system-vat.test.ts | 394 ++++++++++++++++++++ packages/nodejs/test/helpers/kernel.ts | 19 +- packages/nodejs/test/vats/system-vat.js | 109 ++++++ 3 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 packages/nodejs/test/e2e/system-vat.test.ts create mode 100644 packages/nodejs/test/vats/system-vat.js diff --git a/packages/nodejs/test/e2e/system-vat.test.ts b/packages/nodejs/test/e2e/system-vat.test.ts new file mode 100644 index 000000000..a30b2310b --- /dev/null +++ b/packages/nodejs/test/e2e/system-vat.test.ts @@ -0,0 +1,394 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { Kernel, kunser } from '@metamask/ocap-kernel'; +import type { SystemVatConfig, ClusterConfig } from '@metamask/ocap-kernel'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { makeTestKernel } from '../helpers/kernel.ts'; + +const SYSTEM_VAT_BUNDLE_URL = 'http://localhost:3000/system-vat.bundle'; +const SAMPLE_VAT_BUNDLE_URL = 'http://localhost:3000/sample-vat.bundle'; + +describe('System Vat', { timeout: 30_000 }, () => { + let kernel: Kernel | undefined; + let kernelDatabase: KernelDatabase | undefined; + + const makeSystemVatConfig = ( + name: string, + services: string[] = ['kernelFacet'], + ): SystemVatConfig => ({ + name, + bundleSpec: SYSTEM_VAT_BUNDLE_URL, + parameters: { name }, + services, + }); + + afterEach(async () => { + if (kernel) { + const stopResult = kernel.stop(); + kernel = undefined; + await stopResult; + } + if (kernelDatabase) { + kernelDatabase.close(); + kernelDatabase = undefined; + } + }); + + describe('initialization', () => { + it('launches system vat at kernel initialization', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + // System vat should be running (has a vat ID) + expect(kernel.getVatIds().length).toBeGreaterThan(0); + }); + + it('provides system vat root via getSystemVatRoot', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + expect(typeof root).toBe('string'); + expect(root).toMatch(/^ko\d+$/u); + }); + + it('returns undefined for unknown system vat name', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('unknown-vat'); + expect(root).toBeUndefined(); + }); + }); + + describe('kernel services', () => { + it('receives kernelFacet service in bootstrap', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + const result = await kernel.queueMessage(root!, 'hasKernelFacet', []); + await waitUntilQuiescent(); + + expect(kunser(result)).toBe(true); + }); + + it('queries kernel status via kernelFacet', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + const result = await kernel.queueMessage(root!, 'getKernelStatus', []); + await waitUntilQuiescent(); + + const status = kunser(result) as { + vats: unknown[]; + subclusters: unknown[]; + }; + expect(status).toBeDefined(); + expect(Array.isArray(status.vats)).toBe(true); + expect(status.vats).toHaveLength(1); + expect(Array.isArray(status.subclusters)).toBe(true); + expect(status.subclusters).toHaveLength(1); + }); + + it('retrieves subclusters via kernelFacet', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + const result = await kernel.queueMessage(root!, 'getSubclusters', []); + await waitUntilQuiescent(); + + const subclusters = kunser(result) as unknown[]; + expect(Array.isArray(subclusters)).toBe(true); + // At least the system vat's subcluster should exist + expect(subclusters).toHaveLength(1); + }); + }); + + describe('subcluster management', () => { + it('launches subcluster via kernelFacet', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + // Get initial subcluster count + const initialResult = await kernel.queueMessage( + root!, + 'getSubclusters', + [], + ); + await waitUntilQuiescent(); + const initialSubclusters = kunser(initialResult) as unknown[]; + expect(initialSubclusters).toHaveLength(1); + + // Launch a new subcluster via the system vat + const config: ClusterConfig = { + bootstrap: 'child', + vats: { + child: { + bundleSpec: SAMPLE_VAT_BUNDLE_URL, + parameters: { name: 'child-vat' }, + }, + }, + }; + + await kernel.queueMessage(root!, 'launchSubcluster', [config]); + await waitUntilQuiescent(); + + // Verify subcluster was created + const afterResult = await kernel.queueMessage( + root!, + 'getSubclusters', + [], + ); + await waitUntilQuiescent(); + const afterSubclusters = kunser(afterResult) as unknown[]; + + expect(afterSubclusters).toHaveLength(2); + }); + + it('terminates subcluster via kernelFacet', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + // Launch a subcluster to terminate + const config: ClusterConfig = { + bootstrap: 'child', + vats: { + child: { + bundleSpec: SAMPLE_VAT_BUNDLE_URL, + parameters: { name: 'child-vat' }, + }, + }, + }; + + const launchResult = await kernel.queueMessage( + root!, + 'launchSubcluster', + [config], + ); + await waitUntilQuiescent(); + const launchData = kunser(launchResult) as { subclusterId: string }; + const { subclusterId } = launchData; + + // Get count before termination + const beforeResult = await kernel.queueMessage( + root!, + 'getSubclusters', + [], + ); + await waitUntilQuiescent(); + const beforeSubclusters = kunser(beforeResult) as unknown[]; + expect(beforeSubclusters).toHaveLength(2); + + // Terminate the subcluster + await kernel.queueMessage(root!, 'terminateSubcluster', [subclusterId]); + await waitUntilQuiescent(); + + // Verify subcluster was terminated + const afterResult = await kernel.queueMessage( + root!, + 'getSubclusters', + [], + ); + await waitUntilQuiescent(); + const afterSubclusters = kunser(afterResult) as unknown[]; + + expect(afterSubclusters).toHaveLength(1); + }); + }); + + describe('system vat relaunch', () => { + it('terminates and relaunches existing system vat on kernel restart', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + // Get initial subcluster info + const initialSubclusters = kernel.getSubclusters(); + expect(initialSubclusters).toHaveLength(1); + const initialSubclusterId = initialSubclusters[0]!.id; + const initialRoot = kernel.getSystemVatRoot('test-system'); + expect(initialRoot).toBeDefined(); + + // Stop kernel but keep database + await kernel.stop(); + // eslint-disable-next-line require-atomic-updates + kernel = undefined; + + // Restart kernel with same system vat config (resetStorage = false) + // eslint-disable-next-line require-atomic-updates + kernel = await makeTestKernel(kernelDatabase, false, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + // System vat should be relaunched with new subcluster + const newSubclusters = kernel.getSubclusters(); + expect(newSubclusters).toHaveLength(1); + const newSubclusterId = newSubclusters[0]!.id; + + // Subcluster ID should be different (terminated and relaunched) + expect(newSubclusterId).not.toBe(initialSubclusterId); + + // System vat should still be accessible + const newRoot = kernel.getSystemVatRoot('test-system'); + expect(newRoot).toBeDefined(); + expect(newRoot).not.toBe(initialRoot); + + const result = await kernel.queueMessage(newRoot!, 'hasKernelFacet', []); + await waitUntilQuiescent(); + expect(kunser(result)).toBe(true); + }); + + // TODO: We are terminating system vats on restart, so baggage data is not persisted. + // This will be fixed. + it.fails('persists baggage data across kernel restarts', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + const root = kernel.getSystemVatRoot('test-system'); + expect(root).toBeDefined(); + + // Store data in baggage during first incarnation + const testKey = 'persistent-data'; + const testValue = 'hello from first incarnation'; + await kernel.queueMessage(root!, 'storeToBaggage', [testKey, testValue]); + await waitUntilQuiescent(); + + // Verify data was stored + const storedResult = await kernel.queueMessage(root!, 'getFromBaggage', [ + testKey, + ]); + await waitUntilQuiescent(); + expect(kunser(storedResult)).toBe(testValue); + + // Stop kernel but keep database + await kernel.stop(); + // eslint-disable-next-line require-atomic-updates + kernel = undefined; + + // Restart kernel with same system vat config (resetStorage = false) + // eslint-disable-next-line require-atomic-updates + kernel = await makeTestKernel(kernelDatabase, false, { + systemVats: [makeSystemVatConfig('test-system')], + }); + + // Get new root after relaunch + const newRoot = kernel.getSystemVatRoot('test-system'); + expect(newRoot).toBeDefined(); + + // Verify baggage data persisted across restart + const persistedResult = await kernel.queueMessage( + newRoot!, + 'getFromBaggage', + [testKey], + ); + await waitUntilQuiescent(); + expect(kunser(persistedResult)).toBe(testValue); + + // Verify key exists check works + const hasKeyResult = await kernel.queueMessage( + newRoot!, + 'hasBaggageKey', + [testKey], + ); + await waitUntilQuiescent(); + expect(kunser(hasKeyResult)).toBe(true); + + // Verify non-existent key returns false + const noKeyResult = await kernel.queueMessage(newRoot!, 'hasBaggageKey', [ + 'non-existent-key', + ]); + await waitUntilQuiescent(); + expect(kunser(noKeyResult)).toBe(false); + }); + }); + + describe('multiple system vats', () => { + it('launches multiple system vats at kernel initialization', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemVats: [ + makeSystemVatConfig('system-1'), + makeSystemVatConfig('system-2'), + ], + }); + + // Both system vats should have roots + const root1 = kernel.getSystemVatRoot('system-1'); + const root2 = kernel.getSystemVatRoot('system-2'); + expect(root1).toBeDefined(); + expect(root2).toBeDefined(); + expect(root1).not.toBe(root2); + + // Both should have kernelFacet + const result1 = await kernel.queueMessage(root1!, 'hasKernelFacet', []); + await waitUntilQuiescent(); + expect(kunser(result1)).toBe(true); + + const result2 = await kernel.queueMessage(root2!, 'hasKernelFacet', []); + await waitUntilQuiescent(); + expect(kunser(result2)).toBe(true); + + // Should have two subclusters + expect(kernel.getSubclusters()).toHaveLength(2); + }); + }); +}); diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index 975625b12..7668a578c 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -2,31 +2,42 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; +import type { ClusterConfig, SystemVatConfig } from '@metamask/ocap-kernel'; import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; +type MakeTestKernelOptions = { + mnemonic?: string; + systemVats?: SystemVatConfig[]; +}; + /** * Helper function to create a kernel with an existing database. * This avoids creating the database twice. * * @param kernelDatabase - The kernel database to use. * @param resetStorage - Whether to reset the storage. - * @param mnemonic - Optional BIP39 mnemonic for identity recovery. + * @param mnemonicOrOptions - Optional BIP39 mnemonic string or options bag. * @returns The kernel. */ export async function makeTestKernel( kernelDatabase: KernelDatabase, resetStorage: boolean, - mnemonic?: string, + mnemonicOrOptions?: string | MakeTestKernelOptions, ): Promise { + const options: MakeTestKernelOptions = + typeof mnemonicOrOptions === 'string' + ? { mnemonic: mnemonicOrOptions } + : (mnemonicOrOptions ?? {}); + const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ logger: logger.subLogger({ tags: ['platform-services'] }), }); const kernel = await Kernel.make(platformServices, kernelDatabase, { resetStorage, - mnemonic, + mnemonic: options.mnemonic, + systemVats: options.systemVats, logger: logger.subLogger({ tags: ['kernel'] }), }); diff --git a/packages/nodejs/test/vats/system-vat.js b/packages/nodejs/test/vats/system-vat.js new file mode 100644 index 000000000..5a5e9a1d7 --- /dev/null +++ b/packages/nodejs/test/vats/system-vat.js @@ -0,0 +1,109 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for a test system vat that uses kernel services. + * + * @param {object} _ - The vat powers (unused). + * @param {object} params - The vat parameters. + * @param {string} params.name - The name of the vat. Defaults to 'system-vat'. + * @param {object} baggage - The vat's persistent baggage storage. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject(_, { name = 'system-vat' }, baggage) { + let kernelFacet; + + return makeDefaultExo('root', { + /** + * Bootstrap the system vat. + * + * @param {object} _vats - The vats object (unused). + * @param {object} services - The services object. + */ + async bootstrap(_vats, services) { + console.log(`system vat ${name} bootstrap`); + kernelFacet = services.kernelFacet; + }, + + /** + * Check if the kernel facet was received during bootstrap. + * + * @returns {boolean} True if kernelFacet is defined. + */ + hasKernelFacet() { + return kernelFacet !== undefined; + }, + + /** + * Get the kernel status via the kernel facet. + * + * @returns {Promise} The kernel status. + */ + async getKernelStatus() { + return E(kernelFacet).getStatus(); + }, + + /** + * Get all subclusters via the kernel facet. + * + * @returns {Promise} The list of subclusters. + */ + async getSubclusters() { + return E(kernelFacet).getSubclusters(); + }, + + /** + * Launch a subcluster via the kernel facet. + * + * @param {object} config - The cluster configuration. + * @returns {Promise} The launch result. + */ + async launchSubcluster(config) { + return E(kernelFacet).launchSubcluster(config); + }, + + /** + * Terminate a subcluster via the kernel facet. + * + * @param {string} subclusterId - The ID of the subcluster to terminate. + * @returns {Promise} + */ + async terminateSubcluster(subclusterId) { + return E(kernelFacet).terminateSubcluster(subclusterId); + }, + + /** + * Store a value in the baggage. + * + * @param {string} key - The key to store the value under. + * @param {unknown} value - The value to store. + */ + storeToBaggage(key, value) { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + }, + + /** + * Retrieve a value from the baggage. + * + * @param {string} key - The key to retrieve. + * @returns {unknown} The stored value, or undefined if not found. + */ + getFromBaggage(key) { + return baggage.has(key) ? baggage.get(key) : undefined; + }, + + /** + * Check if a key exists in the baggage. + * + * @param {string} key - The key to check. + * @returns {boolean} True if the key exists in baggage. + */ + hasBaggageKey(key) { + return baggage.has(key); + }, + }); +} From a5b98dabbe45b85f2ea7e623d219154c6d062e32 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:19:01 -0800 Subject: [PATCH 17/47] refactor(ocap-kernel): Rename system vats to system subclusters and add persistence - Rename SystemVatConfig to SystemSubclusterConfig with nested config - Rename getSystemVatRoot to getSystemSubclusterRoot - Add persistent storage for system subcluster mappings - Restore system subclusters on kernel restart instead of relaunching - Update kernel-browser-runtime and omnium-gatherum for new API Co-Authored-By: Claude Opus 4.5 --- .../src/kernel-worker/captp/kernel-facade.ts | 6 +- .../src/kernel-worker/kernel-worker.ts | 8 +- packages/kernel-browser-runtime/src/types.ts | 2 +- ...-vat.test.ts => system-subcluster.test.ts} | 161 +++++++++--------- packages/nodejs/test/helpers/kernel.ts | 9 +- packages/nodejs/test/vats/system-vat.js | 17 +- packages/ocap-kernel/src/Kernel.test.ts | 31 +++- packages/ocap-kernel/src/Kernel.ts | 137 +++++++++------ packages/ocap-kernel/src/index.ts | 2 +- packages/ocap-kernel/src/store/index.test.ts | 4 + .../src/store/methods/subclusters.ts | 57 +++++++ packages/ocap-kernel/src/types.ts | 15 +- packages/omnium-gatherum/src/background.ts | 2 +- packages/omnium-gatherum/src/offscreen.ts | 20 ++- 14 files changed, 308 insertions(+), 163 deletions(-) rename packages/nodejs/test/e2e/{system-vat.test.ts => system-subcluster.test.ts} (69%) diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts index 40d060422..48786b0f2 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -43,10 +43,10 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { return { kref: krefString }; }, - getSystemVatRoot: async (name: string) => { - const rootKref = kernel.getSystemVatRoot(name); + getSystemSubclusterRoot: async (name: string) => { + const rootKref = kernel.getSystemSubclusterRoot(name); if (!rootKref) { - throw new Error(`System vat "${name}" not found`); + throw new Error(`System subcluster "${name}" not found`); } return { kref: rootKref }; }, diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index 83b961c0a..0c8255a45 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -56,12 +56,14 @@ async function main(): Promise { const urlParams = new URLSearchParams(globalThis.location.search); const resetStorage = urlParams.get('reset-storage') === 'true'; - const systemVatsParam = urlParams.get('system-vats'); - const systemVats = systemVatsParam ? JSON.parse(systemVatsParam) : undefined; + const systemSubclustersParam = urlParams.get('system-subclusters'); + const systemSubclusters = systemSubclustersParam + ? JSON.parse(systemSubclustersParam) + : undefined; const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { resetStorage, - systemVats, + systemSubclusters, }); const handlerP = kernelP.then((kernel) => { diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index 90591f30b..b6027d785 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -29,6 +29,6 @@ export type KernelFacade = { getStatus: Kernel['getStatus']; pingVat: Kernel['pingVat']; getVatRoot: (krefString: string) => Promise; - getSystemVatRoot: (name: string) => Promise<{ kref: string }>; + getSystemSubclusterRoot: (name: string) => Promise<{ kref: string }>; reset: Kernel['reset']; }; diff --git a/packages/nodejs/test/e2e/system-vat.test.ts b/packages/nodejs/test/e2e/system-subcluster.test.ts similarity index 69% rename from packages/nodejs/test/e2e/system-vat.test.ts rename to packages/nodejs/test/e2e/system-subcluster.test.ts index a30b2310b..c0138c5e9 100644 --- a/packages/nodejs/test/e2e/system-vat.test.ts +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -1,8 +1,11 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; -import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import type { SystemVatConfig, ClusterConfig } from '@metamask/ocap-kernel'; +import type { + SystemSubclusterConfig, + ClusterConfig, +} from '@metamask/ocap-kernel'; +import { delay } from '@ocap/repo-tools/test-utils'; import { describe, it, expect, afterEach } from 'vitest'; import { makeTestKernel } from '../helpers/kernel.ts'; @@ -10,18 +13,25 @@ import { makeTestKernel } from '../helpers/kernel.ts'; const SYSTEM_VAT_BUNDLE_URL = 'http://localhost:3000/system-vat.bundle'; const SAMPLE_VAT_BUNDLE_URL = 'http://localhost:3000/sample-vat.bundle'; -describe('System Vat', { timeout: 30_000 }, () => { +describe('System Subcluster', { timeout: 30_000 }, () => { let kernel: Kernel | undefined; let kernelDatabase: KernelDatabase | undefined; - const makeSystemVatConfig = ( + const makeSystemSubclusterConfig = ( name: string, services: string[] = ['kernelFacet'], - ): SystemVatConfig => ({ + ): SystemSubclusterConfig => ({ name, - bundleSpec: SYSTEM_VAT_BUNDLE_URL, - parameters: { name }, - services, + config: { + bootstrap: name, + vats: { + [name]: { + bundleSpec: SYSTEM_VAT_BUNDLE_URL, + parameters: { name }, + }, + }, + ...(services.length > 0 && { services }), + }, }); afterEach(async () => { @@ -37,41 +47,41 @@ describe('System Vat', { timeout: 30_000 }, () => { }); describe('initialization', () => { - it('launches system vat at kernel initialization', async () => { + it('launches system subcluster at kernel initialization', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - // System vat should be running (has a vat ID) + // System subcluster's bootstrap vat should be running expect(kernel.getVatIds().length).toBeGreaterThan(0); }); - it('provides system vat root via getSystemVatRoot', async () => { + it('provides bootstrap root via getSystemSubclusterRoot', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); expect(typeof root).toBe('string'); expect(root).toMatch(/^ko\d+$/u); }); - it('returns undefined for unknown system vat name', async () => { + it('returns undefined for unknown system subcluster name', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('unknown-vat'); + const root = kernel.getSystemSubclusterRoot('unknown-vat'); expect(root).toBeUndefined(); }); }); @@ -82,14 +92,14 @@ describe('System Vat', { timeout: 30_000 }, () => { dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); const result = await kernel.queueMessage(root!, 'hasKernelFacet', []); - await waitUntilQuiescent(); + await delay(); expect(kunser(result)).toBe(true); }); @@ -99,14 +109,14 @@ describe('System Vat', { timeout: 30_000 }, () => { dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); const result = await kernel.queueMessage(root!, 'getKernelStatus', []); - await waitUntilQuiescent(); + await delay(); const status = kunser(result) as { vats: unknown[]; @@ -124,18 +134,18 @@ describe('System Vat', { timeout: 30_000 }, () => { dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); const result = await kernel.queueMessage(root!, 'getSubclusters', []); - await waitUntilQuiescent(); + await delay(); const subclusters = kunser(result) as unknown[]; expect(Array.isArray(subclusters)).toBe(true); - // At least the system vat's subcluster should exist + // At least the system subcluster should exist expect(subclusters).toHaveLength(1); }); }); @@ -146,10 +156,10 @@ describe('System Vat', { timeout: 30_000 }, () => { dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); // Get initial subcluster count @@ -158,11 +168,11 @@ describe('System Vat', { timeout: 30_000 }, () => { 'getSubclusters', [], ); - await waitUntilQuiescent(); + await delay(); const initialSubclusters = kunser(initialResult) as unknown[]; expect(initialSubclusters).toHaveLength(1); - // Launch a new subcluster via the system vat + // Launch a new subcluster via the system subcluster's bootstrap vat const config: ClusterConfig = { bootstrap: 'child', vats: { @@ -174,7 +184,7 @@ describe('System Vat', { timeout: 30_000 }, () => { }; await kernel.queueMessage(root!, 'launchSubcluster', [config]); - await waitUntilQuiescent(); + await delay(); // Verify subcluster was created const afterResult = await kernel.queueMessage( @@ -182,7 +192,7 @@ describe('System Vat', { timeout: 30_000 }, () => { 'getSubclusters', [], ); - await waitUntilQuiescent(); + await delay(); const afterSubclusters = kunser(afterResult) as unknown[]; expect(afterSubclusters).toHaveLength(2); @@ -193,10 +203,10 @@ describe('System Vat', { timeout: 30_000 }, () => { dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); // Launch a subcluster to terminate @@ -215,7 +225,7 @@ describe('System Vat', { timeout: 30_000 }, () => { 'launchSubcluster', [config], ); - await waitUntilQuiescent(); + await delay(); const launchData = kunser(launchResult) as { subclusterId: string }; const { subclusterId } = launchData; @@ -225,13 +235,13 @@ describe('System Vat', { timeout: 30_000 }, () => { 'getSubclusters', [], ); - await waitUntilQuiescent(); + await delay(); const beforeSubclusters = kunser(beforeResult) as unknown[]; expect(beforeSubclusters).toHaveLength(2); // Terminate the subcluster await kernel.queueMessage(root!, 'terminateSubcluster', [subclusterId]); - await waitUntilQuiescent(); + await delay(); // Verify subcluster was terminated const afterResult = await kernel.queueMessage( @@ -239,27 +249,27 @@ describe('System Vat', { timeout: 30_000 }, () => { 'getSubclusters', [], ); - await waitUntilQuiescent(); + await delay(); const afterSubclusters = kunser(afterResult) as unknown[]; expect(afterSubclusters).toHaveLength(1); }); }); - describe('system vat relaunch', () => { - it('terminates and relaunches existing system vat on kernel restart', async () => { + describe('system subcluster persistence', () => { + it('restores existing system subcluster on kernel restart', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); // Get initial subcluster info const initialSubclusters = kernel.getSubclusters(); expect(initialSubclusters).toHaveLength(1); const initialSubclusterId = initialSubclusters[0]!.id; - const initialRoot = kernel.getSystemVatRoot('test-system'); + const initialRoot = kernel.getSystemSubclusterRoot('test-system'); expect(initialRoot).toBeDefined(); // Stop kernel but keep database @@ -267,54 +277,52 @@ describe('System Vat', { timeout: 30_000 }, () => { // eslint-disable-next-line require-atomic-updates kernel = undefined; - // Restart kernel with same system vat config (resetStorage = false) + // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates kernel = await makeTestKernel(kernelDatabase, false, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - // System vat should be relaunched with new subcluster + // System subcluster should be restored (not relaunched) const newSubclusters = kernel.getSubclusters(); expect(newSubclusters).toHaveLength(1); const newSubclusterId = newSubclusters[0]!.id; - // Subcluster ID should be different (terminated and relaunched) - expect(newSubclusterId).not.toBe(initialSubclusterId); + // Subcluster ID should be the SAME (restored from persistence) + expect(newSubclusterId).toBe(initialSubclusterId); - // System vat should still be accessible - const newRoot = kernel.getSystemVatRoot('test-system'); + // Bootstrap root should be restored + const newRoot = kernel.getSystemSubclusterRoot('test-system'); expect(newRoot).toBeDefined(); - expect(newRoot).not.toBe(initialRoot); + expect(newRoot).toBe(initialRoot); const result = await kernel.queueMessage(newRoot!, 'hasKernelFacet', []); - await waitUntilQuiescent(); + await delay(); expect(kunser(result)).toBe(true); }); - // TODO: We are terminating system vats on restart, so baggage data is not persisted. - // This will be fixed. - it.fails('persists baggage data across kernel restarts', async () => { + it('persists baggage data across kernel restarts', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemVatRoot('test-system'); + const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); // Store data in baggage during first incarnation const testKey = 'persistent-data'; const testValue = 'hello from first incarnation'; await kernel.queueMessage(root!, 'storeToBaggage', [testKey, testValue]); - await waitUntilQuiescent(); + await delay(); // Verify data was stored const storedResult = await kernel.queueMessage(root!, 'getFromBaggage', [ testKey, ]); - await waitUntilQuiescent(); + await delay(); expect(kunser(storedResult)).toBe(testValue); // Stop kernel but keep database @@ -322,15 +330,16 @@ describe('System Vat', { timeout: 30_000 }, () => { // eslint-disable-next-line require-atomic-updates kernel = undefined; - // Restart kernel with same system vat config (resetStorage = false) + // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates kernel = await makeTestKernel(kernelDatabase, false, { - systemVats: [makeSystemVatConfig('test-system')], + systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - // Get new root after relaunch - const newRoot = kernel.getSystemVatRoot('test-system'); + // Get restored root after restart (should be the same as before) + const newRoot = kernel.getSystemSubclusterRoot('test-system'); expect(newRoot).toBeDefined(); + expect(newRoot).toBe(root); // Verify baggage data persisted across restart const persistedResult = await kernel.queueMessage( @@ -338,7 +347,7 @@ describe('System Vat', { timeout: 30_000 }, () => { 'getFromBaggage', [testKey], ); - await waitUntilQuiescent(); + await delay(); expect(kunser(persistedResult)).toBe(testValue); // Verify key exists check works @@ -347,44 +356,44 @@ describe('System Vat', { timeout: 30_000 }, () => { 'hasBaggageKey', [testKey], ); - await waitUntilQuiescent(); + await delay(); expect(kunser(hasKeyResult)).toBe(true); // Verify non-existent key returns false const noKeyResult = await kernel.queueMessage(newRoot!, 'hasBaggageKey', [ 'non-existent-key', ]); - await waitUntilQuiescent(); + await delay(); expect(kunser(noKeyResult)).toBe(false); }); }); - describe('multiple system vats', () => { - it('launches multiple system vats at kernel initialization', async () => { + describe('multiple system subclusters', () => { + it('launches multiple system subclusters at kernel initialization', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); kernel = await makeTestKernel(kernelDatabase, true, { - systemVats: [ - makeSystemVatConfig('system-1'), - makeSystemVatConfig('system-2'), + systemSubclusters: [ + makeSystemSubclusterConfig('system-1'), + makeSystemSubclusterConfig('system-2'), ], }); - // Both system vats should have roots - const root1 = kernel.getSystemVatRoot('system-1'); - const root2 = kernel.getSystemVatRoot('system-2'); + // Both system subclusters should have bootstrap roots + const root1 = kernel.getSystemSubclusterRoot('system-1'); + const root2 = kernel.getSystemSubclusterRoot('system-2'); expect(root1).toBeDefined(); expect(root2).toBeDefined(); expect(root1).not.toBe(root2); // Both should have kernelFacet const result1 = await kernel.queueMessage(root1!, 'hasKernelFacet', []); - await waitUntilQuiescent(); + await delay(); expect(kunser(result1)).toBe(true); const result2 = await kernel.queueMessage(root2!, 'hasKernelFacet', []); - await waitUntilQuiescent(); + await delay(); expect(kunser(result2)).toBe(true); // Should have two subclusters diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index 7668a578c..869de4234 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -2,13 +2,16 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import type { ClusterConfig, SystemVatConfig } from '@metamask/ocap-kernel'; +import type { + ClusterConfig, + SystemSubclusterConfig, +} from '@metamask/ocap-kernel'; import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; type MakeTestKernelOptions = { mnemonic?: string; - systemVats?: SystemVatConfig[]; + systemSubclusters?: SystemSubclusterConfig[]; }; /** @@ -37,7 +40,7 @@ export async function makeTestKernel( const kernel = await Kernel.make(platformServices, kernelDatabase, { resetStorage, mnemonic: options.mnemonic, - systemVats: options.systemVats, + systemSubclusters: options.systemSubclusters, logger: logger.subLogger({ tags: ['kernel'] }), }); diff --git a/packages/nodejs/test/vats/system-vat.js b/packages/nodejs/test/vats/system-vat.js index 5a5e9a1d7..143350aef 100644 --- a/packages/nodejs/test/vats/system-vat.js +++ b/packages/nodejs/test/vats/system-vat.js @@ -2,7 +2,7 @@ import { E } from '@endo/eventual-send'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; /** - * Build function for a test system vat that uses kernel services. + * Build function for a test vat that runs in a system subcluster and uses kernel services. * * @param {object} _ - The vat powers (unused). * @param {object} params - The vat parameters. @@ -11,18 +11,25 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; * @returns {object} The root object for the new vat. */ export function buildRootObject(_, { name = 'system-vat' }, baggage) { - let kernelFacet; + // Restore kernelFacet from baggage if available (for resuscitation) + let kernelFacet = baggage.has('kernelFacet') + ? baggage.get('kernelFacet') + : undefined; return makeDefaultExo('root', { /** - * Bootstrap the system vat. + * Bootstrap the vat. * * @param {object} _vats - The vats object (unused). * @param {object} services - The services object. */ async bootstrap(_vats, services) { - console.log(`system vat ${name} bootstrap`); - kernelFacet = services.kernelFacet; + console.log(`system subcluster vat ${name} bootstrap`); + if (!kernelFacet) { + kernelFacet = services.kernelFacet; + // Store in baggage for persistence across restarts + baggage.init('kernelFacet', kernelFacet); + } }, /** diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index b79219560..bcc11aaf1 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -820,24 +820,37 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toHaveLength(0); }); - it('clears system vat roots', async () => { + it('clears system subcluster roots', async () => { const mockDb = makeMapKernelDatabase(); const kernel = await Kernel.make(mockPlatformServices, mockDb, { - systemVats: [ + systemSubclusters: [ { - name: 'testSystemVat', - sourceSpec: 'system-vat.js', + name: 'testSystemSubcluster', + config: { + bootstrap: 'testSystemSubcluster', + vats: { + testSystemSubcluster: { + sourceSpec: 'system-vat.js', + }, + }, + }, }, ], }); - // Verify system vat root was stored - expect(kernel.getSystemVatRoot('testSystemVat')).toBeDefined(); - expect(kernel.getSystemVatRoot('testSystemVat')).toMatch(/^ko\d+$/u); + // Verify system subcluster bootstrap root was stored + expect( + kernel.getSystemSubclusterRoot('testSystemSubcluster'), + ).toBeDefined(); + expect(kernel.getSystemSubclusterRoot('testSystemSubcluster')).toMatch( + /^ko\d+$/u, + ); await kernel.reset(); - // Verify system vat roots are cleared - expect(kernel.getSystemVatRoot('testSystemVat')).toBeUndefined(); + // Verify system subcluster roots are cleared + expect( + kernel.getSystemSubclusterRoot('testSystemSubcluster'), + ).toBeUndefined(); }); it('logs an error if resetting the kernel state fails', async () => { diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 0fa205097..c41015976 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -24,7 +24,7 @@ import type { Subcluster, SubclusterLaunchResult, EndpointHandle, - SystemVatConfig, + SystemSubclusterConfig, } from './types.ts'; import { isVatId, isRemoteId } from './types.ts'; import { SubclusterManager } from './vats/SubclusterManager.ts'; @@ -51,8 +51,8 @@ export class Kernel { /** Manages subcluster operations */ readonly #subclusterManager: SubclusterManager; - /** Stores root krefs of launched system vats */ - readonly #systemVatRoots: Map = new Map(); + /** Stores bootstrap root krefs of launched system subclusters */ + readonly #systemSubclusterRoots: Map = new Map(); /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -191,7 +191,7 @@ export class Kernel { * @param options.logger - Optional logger for error and diagnostic output. * @param options.keySeed - Optional seed for libp2p key generation. * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. - * @param options.systemVats - Optional array of system vat configurations to launch at init. + * @param options.systemSubclusters - Optional array of system subcluster configurations. * @returns A promise for the new kernel instance. */ static async make( @@ -202,20 +202,22 @@ export class Kernel { logger?: Logger; keySeed?: string | undefined; mnemonic?: string | undefined; - systemVats?: SystemVatConfig[]; + systemSubclusters?: SystemSubclusterConfig[]; } = {}, ): Promise { const kernel = new Kernel(platformServices, kernelDatabase, options); - await kernel.#init(options.systemVats); + await kernel.#init(options.systemSubclusters); return kernel; } /** * Start the kernel running. * - * @param systemVatConfigs - Optional array of system vat configurations to launch. + * @param systemSubclusterConfigs - Optional array of system subcluster configurations. */ - async #init(systemVatConfigs?: SystemVatConfig[]): Promise { + async #init( + systemSubclusterConfigs?: SystemSubclusterConfig[], + ): Promise { // Set up the remote message handler this.#remoteManager.setMessageHandler( async (from: string, message: string) => @@ -228,7 +230,7 @@ export class Kernel { // Start the kernel queue processing (non-blocking) // This runs for the entire lifetime of the kernel - // Must start before launching system vats since launchSubcluster awaits bootstrap results + // Must start before initializing system subclusters since launchSubcluster awaits bootstrap results this.#kernelQueue .run(this.#kernelRouter.deliver.bind(this.#kernelRouter)) .catch((error) => { @@ -239,50 +241,79 @@ export class Kernel { // Don't re-throw to avoid unhandled rejection in this long-running task }); - // Launch system vats after queue is running - if (systemVatConfigs && systemVatConfigs.length > 0) { - // Ensure kernel facet is registered before launching system vats - this.#registerKernelFacet(); - await this.#launchSystemVats(systemVatConfigs); - } + // Initialize system subclusters after queue is running + await this.#initSystemSubclusters(systemSubclusterConfigs ?? []); } /** - * Launch system vats from their configurations. - * If a system vat subcluster already exists (from a previous session), - * it will be terminated and relaunched to ensure a clean state. + * Initialize system subclusters. + * For existing system subclusters (from a previous session), restore their + * root references from persistence. For new system subclusters, launch them. * - * @param configs - Array of system vat configurations. - */ - async #launchSystemVats(configs: SystemVatConfig[]): Promise { - for (const config of configs) { - const { name, services, ...vatConfig } = config; - - // Terminate any existing subcluster with the same bootstrap name - const existingSubcluster = this.getSubclusters().find( - (sc) => sc.config.bootstrap === name, - ); - if (existingSubcluster) { - this.#logger.info( - `Terminating existing system vat "${name}" for relaunch`, + * @param configs - Array of system subcluster configurations. + */ + async #initSystemSubclusters( + configs: SystemSubclusterConfig[], + ): Promise { + // First, restore persisted system subcluster mappings to #systemSubclusterRoots + const persistedMappings = + this.#kernelStore.getAllSystemSubclusterMappings(); + + // Early return if no system subclusters to handle + if (persistedMappings.size === 0 && configs.length === 0) { + return; + } + + // Ensure kernel facet is registered before initializing system subclusters + this.#registerKernelFacet(); + + for (const [name, subclusterId] of persistedMappings) { + const subcluster = this.getSubcluster(subclusterId); + if (subcluster) { + // Subcluster exists - get its bootstrap root from the already-resuscitated vats + const bootstrapVatId = subcluster.vats[0]; // bootstrap vat is first + if (bootstrapVatId) { + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (rootKref) { + this.#systemSubclusterRoots.set(name, rootKref); + this.#logger.info(`Restored system subcluster "${name}"`); + } else { + this.#logger.warn( + `System subcluster "${name}" has no root object, will relaunch`, + ); + // Clean up the stale mapping + this.#kernelStore.deleteSystemSubclusterMapping(name); + } + } + } else { + // Subcluster no longer exists - clean up the stale mapping + this.#logger.warn( + `System subcluster "${name}" mapping points to non-existent subcluster ${subclusterId}, cleaning up`, ); - await this.terminateSubcluster(existingSubcluster.id); + this.#kernelStore.deleteSystemSubclusterMapping(name); } + } - // Launch system vat - const clusterConfig: ClusterConfig = { - bootstrap: name, - vats: { [name]: vatConfig }, - ...(services && { services }), - }; - const result = await this.launchSubcluster(clusterConfig); - this.#systemVatRoots.set(name, result.bootstrapRootKref); - this.#logger.info(`System vat "${name}" launched`); + // Then, launch any NEW system subclusters not already persisted + for (const { name, config } of configs) { + if (this.#systemSubclusterRoots.has(name)) { + // Already restored from persistence - skip + continue; + } + + // New system subcluster - launch it + const result = await this.launchSubcluster(config); + this.#systemSubclusterRoots.set(name, result.bootstrapRootKref); + + // Persist the mapping + this.#kernelStore.setSystemSubclusterMapping(name, result.subclusterId); + + this.#logger.info(`Launched new system subcluster "${name}"`); } } /** - * Register the kernel facet as a kernel service for system vats. + * Register the kernel facet as a kernel service for system subclusters. */ #registerKernelFacet(): void { const kernelFacet = makeKernelFacet({ @@ -377,6 +408,16 @@ export class Kernel { * @returns A promise that resolves when termination is complete. */ async terminateSubcluster(subclusterId: string): Promise { + // Check if this is a system subcluster and clean up the mapping + const mappings = this.#kernelStore.getAllSystemSubclusterMappings(); + for (const [name, mappedSubclusterId] of mappings) { + if (mappedSubclusterId === subclusterId) { + this.#systemSubclusterRoots.delete(name); + this.#kernelStore.deleteSystemSubclusterMapping(name); + this.#logger.info(`Cleaned up system subcluster mapping "${name}"`); + break; + } + } return this.#subclusterManager.terminateSubcluster(subclusterId); } @@ -412,13 +453,13 @@ export class Kernel { } /** - * Get the root kref of a system vat by name. + * Get the bootstrap root kref of a system subcluster by name. * - * @param name - The name of the system vat. - * @returns The root kref or undefined if not found. + * @param name - The name of the system subcluster. + * @returns The bootstrap root kref or undefined if not found. */ - getSystemVatRoot(name: string): KRef | undefined { - return this.#systemVatRoots.get(name); + getSystemSubclusterRoot(name: string): KRef | undefined { + return this.#systemSubclusterRoots.get(name); } /** @@ -617,7 +658,7 @@ export class Kernel { await this.#kernelQueue.waitForCrank(); try { await this.terminateAllVats(); - this.#systemVatRoots.clear(); + this.#systemSubclusterRoots.clear(); this.#resetKernelState(); } catch (error) { this.#logger.error('Error resetting kernel:', error); diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index d6636e4e2..1bd3ddd81 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -15,7 +15,7 @@ export type { Subcluster, SubclusterId, SubclusterLaunchResult, - SystemVatConfig, + SystemSubclusterConfig, } from './types.ts'; export type { RemoteMessageHandler, diff --git a/packages/ocap-kernel/src/store/index.test.ts b/packages/ocap-kernel/src/store/index.test.ts index 65d21779c..6f89e0fb1 100644 --- a/packages/ocap-kernel/src/store/index.test.ts +++ b/packages/ocap-kernel/src/store/index.test.ts @@ -66,6 +66,7 @@ describe('kernel store', () => { 'deleteRemotePendingState', 'deleteSubcluster', 'deleteSubclusterVat', + 'deleteSystemSubclusterMapping', 'deleteVat', 'deleteVatConfig', 'dequeueRun', @@ -79,6 +80,7 @@ describe('kernel store', () => { 'forgetKref', 'forgetTerminatedVat', 'getAllRemoteRecords', + 'getAllSystemSubclusterMappings', 'getAllVatRecords', 'getGCActions', 'getImporters', @@ -104,6 +106,7 @@ describe('kernel store', () => { 'getSubcluster', 'getSubclusterVats', 'getSubclusters', + 'getSystemSubclusterMapping', 'getTerminatedVats', 'getVatConfig', 'getVatIDs', @@ -151,6 +154,7 @@ describe('kernel store', () => { 'setRemoteNextSendSeq', 'setRemoteStartSeq', 'setRevoked', + 'setSystemSubclusterMapping', 'setVatConfig', 'startCrank', 'translateCapDataEtoK', diff --git a/packages/ocap-kernel/src/store/methods/subclusters.ts b/packages/ocap-kernel/src/store/methods/subclusters.ts index 8b1260a7a..3838ed02e 100644 --- a/packages/ocap-kernel/src/store/methods/subclusters.ts +++ b/packages/ocap-kernel/src/store/methods/subclusters.ts @@ -239,6 +239,59 @@ export function getSubclusterMethods(ctx: StoreContext) { deleteSubclusterVat(subclusterId, vatId); } + // System subcluster mapping methods + + /** + * Get the subcluster ID for a system subcluster by name. + * + * @param name - The name of the system subcluster. + * @returns The subcluster ID, or undefined if not found. + */ + function getSystemSubclusterMapping(name: string): SubclusterId | undefined { + return kv.get(`systemSubcluster.${name}`); + } + + /** + * Set the subcluster ID for a system subcluster by name. + * + * @param name - The name of the system subcluster. + * @param subclusterId - The subcluster ID to associate with the name. + */ + function setSystemSubclusterMapping( + name: string, + subclusterId: SubclusterId, + ): void { + kv.set(`systemSubcluster.${name}`, subclusterId); + } + + /** + * Delete the mapping for a system subcluster by name. + * + * @param name - The name of the system subcluster to delete. + */ + function deleteSystemSubclusterMapping(name: string): void { + kv.delete(`systemSubcluster.${name}`); + } + + /** + * Get all system subcluster mappings. + * + * @returns A Map of system subcluster names to their subcluster IDs. + */ + function getAllSystemSubclusterMappings(): Map { + const { getPrefixedKeys } = getBaseMethods(kv); + const prefix = 'systemSubcluster.'; + const mappings = new Map(); + for (const key of getPrefixedKeys(prefix)) { + const name = key.slice(prefix.length); + const subclusterId = kv.get(key); + if (subclusterId) { + mappings.set(name, subclusterId); + } + } + return mappings; + } + return { addSubcluster, getSubcluster, @@ -250,5 +303,9 @@ export function getSubclusterMethods(ctx: StoreContext) { getVatSubcluster, clearEmptySubclusters, removeVatFromSubcluster, + getSystemSubclusterMapping, + setSystemSubclusterMapping, + deleteSystemSubclusterMapping, + getAllSystemSubclusterMappings, }; } diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index fc6a90b35..52dc339ae 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -542,13 +542,14 @@ export type EndpointHandle = { }; /** - * Configuration for a system vat. - * System vats are statically declared at kernel initialization and can - * receive powerful kernel services not available to normal vats. + * Configuration for a system subcluster. + * System subclusters are statically declared at kernel initialization and can + * receive powerful kernel services not available to normal subclusters. + * They persist across kernel restarts, just like regular subclusters. */ -export type SystemVatConfig = VatConfig & { - /** Unique name for this system vat (used for retrieval via getSystemVatRoot) */ +export type SystemSubclusterConfig = { + /** Unique name for this system subcluster (used for retrieval via `getSystemSubclusterRoot`) */ name: string; - /** Array of kernel service names this system vat requires */ - services?: string[]; + /** The cluster configuration */ + config: ClusterConfig; }; diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 0db4d3912..d26ad18fb 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -111,7 +111,7 @@ async function main(): Promise { // Set up controller vat initialization (runs concurrently with stream drain) E(kernelP) - .getSystemVatRoot('omnium-controllers') + .getSystemSubclusterRoot('omnium-controllers') .then(({ kref }) => { globals.setControllerVatKref(kref); logger.info('Controller vat initialized'); diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 480cf5810..2a4abc2be 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -72,16 +72,24 @@ async function makeKernelWorker(): Promise< const workerUrlParams = new URLSearchParams(relayQueryString); workerUrlParams.set('reset-storage', process.env.RESET_STORAGE ?? 'false'); - // Configure system vats to launch at kernel initialization - const systemVats = [ + // Configure system subclusters to launch at kernel initialization + const systemSubclusters = [ { name: 'omnium-controllers', - bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), - services: ['kernelFacet'], - globals: ['Date'], + config: { + bootstrap: 'omnium-controllers', + vats: { + 'omnium-controllers': { + bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), + parameters: {}, + globals: ['Date'], + }, + }, + services: ['kernelFacet'], + }, }, ]; - workerUrlParams.set('system-vats', JSON.stringify(systemVats)); + workerUrlParams.set('system-subclusters', JSON.stringify(systemSubclusters)); const workerUrl = new URL('kernel-worker.js', import.meta.url); workerUrl.search = workerUrlParams.toString(); From 0219613fa65fa8ed00e0d9033aca7b3553058ae9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:38:05 -0800 Subject: [PATCH 18/47] refactor(ocap-kernel): Clean up orphaned system subclusters on kernel boot - Add #handlePersistedSystemSubclusters to delete orphaned subclusters (those without configs) before vat initialization - Split system subcluster init into restore (before vats) and launch (after queue) - Add SubclusterManager.deleteSubcluster for deleting without terminating - Make kernel facet registration idempotent with #ensureKernelFacetRegistered - Add test for orphaned system subcluster cleanup Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/Kernel.test.ts | 48 +++++++ packages/ocap-kernel/src/Kernel.ts | 127 ++++++++++++------ .../ocap-kernel/src/vats/SubclusterManager.ts | 22 +++ 3 files changed, 158 insertions(+), 39 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index bcc11aaf1..172ddc8c6 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -873,6 +873,54 @@ describe('Kernel', () => { }); }); + describe('system subcluster cleanup', () => { + it('deletes orphaned system subclusters without starting their vats', async () => { + const db = makeMapKernelDatabase(); + const systemSubclusterConfig = { + name: 'testSystemSubcluster', + config: { + bootstrap: 'testSystemSubcluster', + vats: { + testSystemSubcluster: { + sourceSpec: 'system-vat.js', + }, + }, + }, + }; + + // Create kernel with system subcluster + const kernel1 = await Kernel.make(mockPlatformServices, db, { + systemSubclusters: [systemSubclusterConfig], + }); + expect(kernel1.getSubclusters()).toHaveLength(1); + expect(kernel1.getVatIds()).toStrictEqual(['v1']); + expect( + kernel1.getSystemSubclusterRoot('testSystemSubcluster'), + ).toBeDefined(); + + // Stop kernel + await kernel1.stop(); + + // Clear spies to track what happens on restart + launchWorkerMock.mockClear(); + makeVatHandleMock.mockClear(); + + // Restart kernel WITHOUT the system subcluster config + const kernel2 = await Kernel.make(mockPlatformServices, db, { + systemSubclusters: [], // No system subclusters + }); + + // The orphaned system subcluster should have been deleted without starting vats + expect(launchWorkerMock).not.toHaveBeenCalled(); + expect(makeVatHandleMock).not.toHaveBeenCalled(); + expect(kernel2.getSubclusters()).toHaveLength(0); + expect(kernel2.getVatIds()).toStrictEqual([]); + expect( + kernel2.getSystemSubclusterRoot('testSystemSubcluster'), + ).toBeUndefined(); + }); + }); + describe('revoke and isRevoked', () => { it('reflect when an object is revoked', async () => { const kernel = await Kernel.make( diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index c41015976..c94f9f457 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -54,6 +54,9 @@ export class Kernel { /** Stores bootstrap root krefs of launched system subclusters */ readonly #systemSubclusterRoots: Map = new Map(); + /** Whether the kernel facet has been registered */ + #kernelFacetRegistered = false; + /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -218,19 +221,25 @@ export class Kernel { async #init( systemSubclusterConfigs?: SystemSubclusterConfig[], ): Promise { + const configs = systemSubclusterConfigs ?? []; + // Set up the remote message handler this.#remoteManager.setMessageHandler( async (from: string, message: string) => this.#remoteManager.handleRemoteMessage(from, message), ); + // Handle persisted system subclusters before initializing vats: + // - Delete orphaned ones (no longer in config) so their vats aren't started + // - Restore mappings for existing ones (registers kernelFacet if needed) + this.#handlePersistedSystemSubclusters(configs); + // Start all vats that were previously running before starting the queue // This ensures that any messages in the queue have their target vats ready await this.#vatManager.initializeAllVats(); // Start the kernel queue processing (non-blocking) // This runs for the entire lifetime of the kernel - // Must start before initializing system subclusters since launchSubcluster awaits bootstrap results this.#kernelQueue .run(this.#kernelRouter.deliver.bind(this.#kernelRouter)) .catch((error) => { @@ -241,67 +250,101 @@ export class Kernel { // Don't re-throw to avoid unhandled rejection in this long-running task }); - // Initialize system subclusters after queue is running - await this.#initSystemSubclusters(systemSubclusterConfigs ?? []); + // Launch any NEW system subclusters (requires queue to be running) + // It's safe to do this last because messages and existing vats cannot possibly + // be targeting these new subclusters. + await this.#launchNewSystemSubclusters(configs); } /** - * Initialize system subclusters. - * For existing system subclusters (from a previous session), restore their - * root references from persistence. For new system subclusters, launch them. + * Handle persisted system subclusters before vat initialization. + * - Deletes orphaned subclusters (no longer in config) so their vats aren't started + * - Restores mappings for existing subclusters (registers kernelFacet if needed) * * @param configs - Array of system subcluster configurations. */ - async #initSystemSubclusters( - configs: SystemSubclusterConfig[], - ): Promise { - // First, restore persisted system subcluster mappings to #systemSubclusterRoots + #handlePersistedSystemSubclusters(configs: SystemSubclusterConfig[]): void { const persistedMappings = this.#kernelStore.getAllSystemSubclusterMappings(); - // Early return if no system subclusters to handle - if (persistedMappings.size === 0 && configs.length === 0) { + if (persistedMappings.size === 0) { return; } - // Ensure kernel facet is registered before initializing system subclusters - this.#registerKernelFacet(); + const configNames = new Set(configs.map((config) => config.name)); + + // Track if we have any valid persisted subclusters to restore + let hasValidPersistedSubclusters = false; for (const [name, subclusterId] of persistedMappings) { + if (!configNames.has(name)) { + // This system subcluster no longer has a config - delete it + this.#logger.info( + `System subcluster "${name}" no longer in config, deleting`, + ); + this.#subclusterManager.deleteSubcluster(subclusterId); + this.#kernelStore.deleteSystemSubclusterMapping(name); + continue; + } + + // Subcluster has a config - try to restore it const subcluster = this.getSubcluster(subclusterId); - if (subcluster) { - // Subcluster exists - get its bootstrap root from the already-resuscitated vats - const bootstrapVatId = subcluster.vats[0]; // bootstrap vat is first - if (bootstrapVatId) { - const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); - if (rootKref) { - this.#systemSubclusterRoots.set(name, rootKref); - this.#logger.info(`Restored system subcluster "${name}"`); - } else { - this.#logger.warn( - `System subcluster "${name}" has no root object, will relaunch`, - ); - // Clean up the stale mapping - this.#kernelStore.deleteSystemSubclusterMapping(name); - } - } - } else { - // Subcluster no longer exists - clean up the stale mapping + if (!subcluster) { this.#logger.warn( `System subcluster "${name}" mapping points to non-existent subcluster ${subclusterId}, cleaning up`, ); this.#kernelStore.deleteSystemSubclusterMapping(name); + continue; + } + + const bootstrapVatId = subcluster.vats[0]; + if (!bootstrapVatId) { + continue; } - } - // Then, launch any NEW system subclusters not already persisted - for (const { name, config } of configs) { - if (this.#systemSubclusterRoots.has(name)) { - // Already restored from persistence - skip + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (!rootKref) { + this.#logger.warn( + `System subcluster "${name}" has no root object, will relaunch`, + ); + this.#kernelStore.deleteSystemSubclusterMapping(name); continue; } - // New system subcluster - launch it + // Valid subcluster to restore - register kernelFacet on first valid one + if (!hasValidPersistedSubclusters) { + this.#ensureKernelFacetRegistered(); + hasValidPersistedSubclusters = true; + } + + this.#systemSubclusterRoots.set(name, rootKref); + this.#logger.info(`Restored system subcluster "${name}"`); + } + } + + /** + * Launch new system subclusters that aren't already in persistence. + * This must be called after the kernel queue is running since launchSubcluster + * sends bootstrap messages. + * + * @param configs - Array of system subcluster configurations. + */ + async #launchNewSystemSubclusters( + configs: SystemSubclusterConfig[], + ): Promise { + // Filter to only configs that weren't restored from persistence + const newConfigs = configs.filter( + ({ name }) => !this.#systemSubclusterRoots.has(name), + ); + + if (newConfigs.length === 0) { + return; + } + + // Ensure kernelFacet is registered for new system subclusters + this.#ensureKernelFacetRegistered(); + + for (const { name, config } of newConfigs) { const result = await this.launchSubcluster(config); this.#systemSubclusterRoots.set(name, result.bootstrapRootKref); @@ -314,8 +357,13 @@ export class Kernel { /** * Register the kernel facet as a kernel service for system subclusters. + * This is idempotent - calling it multiple times has no effect after the first. */ - #registerKernelFacet(): void { + #ensureKernelFacetRegistered(): void { + if (this.#kernelFacetRegistered) { + return; + } + const kernelFacet = makeKernelFacet({ launchSubcluster: this.launchSubcluster.bind(this), terminateSubcluster: this.terminateSubcluster.bind(this), @@ -328,6 +376,7 @@ export class Kernel { 'kernelFacet', kernelFacet, ); + this.#kernelFacetRegistered = true; } /** diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index 663751d2c..f8498f7e1 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -183,6 +183,28 @@ export class SubclusterManager { return this.#kernelStore.getSubclusterVats(subclusterId); } + /** + * Deletes a subcluster and its vat data from storage without terminating running vats. + * This is used for cleaning up orphaned subclusters before vats are started. + * + * @param subclusterId - The ID of the subcluster to delete. + */ + deleteSubcluster(subclusterId: string): void { + const subcluster = this.#kernelStore.getSubcluster(subclusterId); + if (!subcluster) { + return; + } + + // Delete vat configs and mark vats as terminated so their data will be cleaned up + for (const vatId of subcluster.vats) { + this.#kernelStore.deleteVatConfig(vatId); + this.#kernelStore.markVatAsTerminated(vatId); + } + + // Delete the subcluster record + this.#kernelStore.deleteSubcluster(subclusterId); + } + /** * Launches all vats for a subcluster and sets up their bootstrap connections. * From b351ff972332819cac5f47e6aa6c41f8cfa4a79b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:49:27 -0800 Subject: [PATCH 19/47] refactor(nodejs): Convert system-vat test to TypeScript - Add proper TypeScript types for KernelFacet, BootstrapServices, VatParameters - Use types from @metamask/ocap-kernel (Baggage, ClusterConfig, etc.) - Remove JSDoc type annotations in favor of TypeScript types Co-Authored-By: Claude Opus 4.5 --- packages/nodejs/test/vats/system-vat.js | 116 ----------------- packages/nodejs/test/vats/system-vat.ts | 157 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 116 deletions(-) delete mode 100644 packages/nodejs/test/vats/system-vat.js create mode 100644 packages/nodejs/test/vats/system-vat.ts diff --git a/packages/nodejs/test/vats/system-vat.js b/packages/nodejs/test/vats/system-vat.js deleted file mode 100644 index 143350aef..000000000 --- a/packages/nodejs/test/vats/system-vat.js +++ /dev/null @@ -1,116 +0,0 @@ -import { E } from '@endo/eventual-send'; -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; - -/** - * Build function for a test vat that runs in a system subcluster and uses kernel services. - * - * @param {object} _ - The vat powers (unused). - * @param {object} params - The vat parameters. - * @param {string} params.name - The name of the vat. Defaults to 'system-vat'. - * @param {object} baggage - The vat's persistent baggage storage. - * @returns {object} The root object for the new vat. - */ -export function buildRootObject(_, { name = 'system-vat' }, baggage) { - // Restore kernelFacet from baggage if available (for resuscitation) - let kernelFacet = baggage.has('kernelFacet') - ? baggage.get('kernelFacet') - : undefined; - - return makeDefaultExo('root', { - /** - * Bootstrap the vat. - * - * @param {object} _vats - The vats object (unused). - * @param {object} services - The services object. - */ - async bootstrap(_vats, services) { - console.log(`system subcluster vat ${name} bootstrap`); - if (!kernelFacet) { - kernelFacet = services.kernelFacet; - // Store in baggage for persistence across restarts - baggage.init('kernelFacet', kernelFacet); - } - }, - - /** - * Check if the kernel facet was received during bootstrap. - * - * @returns {boolean} True if kernelFacet is defined. - */ - hasKernelFacet() { - return kernelFacet !== undefined; - }, - - /** - * Get the kernel status via the kernel facet. - * - * @returns {Promise} The kernel status. - */ - async getKernelStatus() { - return E(kernelFacet).getStatus(); - }, - - /** - * Get all subclusters via the kernel facet. - * - * @returns {Promise} The list of subclusters. - */ - async getSubclusters() { - return E(kernelFacet).getSubclusters(); - }, - - /** - * Launch a subcluster via the kernel facet. - * - * @param {object} config - The cluster configuration. - * @returns {Promise} The launch result. - */ - async launchSubcluster(config) { - return E(kernelFacet).launchSubcluster(config); - }, - - /** - * Terminate a subcluster via the kernel facet. - * - * @param {string} subclusterId - The ID of the subcluster to terminate. - * @returns {Promise} - */ - async terminateSubcluster(subclusterId) { - return E(kernelFacet).terminateSubcluster(subclusterId); - }, - - /** - * Store a value in the baggage. - * - * @param {string} key - The key to store the value under. - * @param {unknown} value - The value to store. - */ - storeToBaggage(key, value) { - if (baggage.has(key)) { - baggage.set(key, value); - } else { - baggage.init(key, value); - } - }, - - /** - * Retrieve a value from the baggage. - * - * @param {string} key - The key to retrieve. - * @returns {unknown} The stored value, or undefined if not found. - */ - getFromBaggage(key) { - return baggage.has(key) ? baggage.get(key) : undefined; - }, - - /** - * Check if a key exists in the baggage. - * - * @param {string} key - The key to check. - * @returns {boolean} True if the key exists in baggage. - */ - hasBaggageKey(key) { - return baggage.has(key); - }, - }); -} diff --git a/packages/nodejs/test/vats/system-vat.ts b/packages/nodejs/test/vats/system-vat.ts new file mode 100644 index 000000000..967b7b526 --- /dev/null +++ b/packages/nodejs/test/vats/system-vat.ts @@ -0,0 +1,157 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { + Baggage, + ClusterConfig, + KernelStatus, + Subcluster, + SubclusterLaunchResult, +} from '@metamask/ocap-kernel'; + +/** + * Kernel facet interface for system vat operations. + */ +type KernelFacet = { + getStatus: () => Promise; + getSubclusters: () => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; +}; + +/** + * Services provided to the system vat during bootstrap. + */ +type BootstrapServices = { + kernelFacet?: KernelFacet; +}; + +/** + * Parameters for the system vat. + */ +type VatParameters = { + name?: string; +}; + +/** + * Build function for a test vat that runs in a system subcluster and uses kernel services. + * + * @param _vatPowers - The vat powers (unused). + * @param parameters - The vat parameters. + * @param baggage - The vat's persistent baggage storage. + * @returns The root object for the new vat. + */ +export function buildRootObject( + _vatPowers: unknown, + parameters: VatParameters, + baggage: Baggage, +) { + const name = parameters.name ?? 'system-vat'; + + // Restore kernelFacet from baggage if available (for resuscitation) + let kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') + ? (baggage.get('kernelFacet') as KernelFacet) + : undefined; + + return makeDefaultExo('root', { + /** + * Bootstrap the vat. + * + * @param _vats - The vats object (unused). + * @param services - The services object. + */ + async bootstrap( + _vats: unknown, + services: BootstrapServices, + ): Promise { + console.log(`system subcluster vat ${name} bootstrap`); + if (!kernelFacet) { + kernelFacet = services.kernelFacet; + // Store in baggage for persistence across restarts + baggage.init('kernelFacet', kernelFacet); + } + }, + + /** + * Check if the kernel facet was received during bootstrap. + * + * @returns True if kernelFacet is defined. + */ + hasKernelFacet(): boolean { + return kernelFacet !== undefined; + }, + + /** + * Get the kernel status via the kernel facet. + * + * @returns The kernel status. + */ + async getKernelStatus(): Promise { + return E(kernelFacet).getStatus(); + }, + + /** + * Get all subclusters via the kernel facet. + * + * @returns The list of subclusters. + */ + async getSubclusters(): Promise { + return E(kernelFacet).getSubclusters(); + }, + + /** + * Launch a subcluster via the kernel facet. + * + * @param config - The cluster configuration. + * @returns The launch result. + */ + async launchSubcluster( + config: ClusterConfig, + ): Promise { + return E(kernelFacet).launchSubcluster(config); + }, + + /** + * Terminate a subcluster via the kernel facet. + * + * @param subclusterId - The ID of the subcluster to terminate. + * @returns A promise that resolves when the subcluster is terminated. + */ + async terminateSubcluster(subclusterId: string): Promise { + return E(kernelFacet).terminateSubcluster(subclusterId); + }, + + /** + * Store a value in the baggage. + * + * @param key - The key to store the value under. + * @param value - The value to store. + */ + storeToBaggage(key: string, value: unknown): void { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + }, + + /** + * Retrieve a value from the baggage. + * + * @param key - The key to retrieve. + * @returns The stored value, or undefined if not found. + */ + getFromBaggage(key: string): unknown { + return baggage.has(key) ? baggage.get(key) : undefined; + }, + + /** + * Check if a key exists in the baggage. + * + * @param key - The key to check. + * @returns True if the key exists in baggage. + */ + hasBaggageKey(key: string): boolean { + return baggage.has(key); + }, + }); +} From 2152f04814a6704269e33105cf74b2cc88cc386b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:27:14 -0800 Subject: [PATCH 20/47] fix(ocap-kernel): Throw on corrupted system subcluster state Throw errors instead of silently recovering when a persisted system subcluster has an empty vats array or missing root object. These conditions indicate database corruption and should fail fast. Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/Kernel.test.ts | 66 +++++++++++++++++++++++++ packages/ocap-kernel/src/Kernel.ts | 10 ++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 172ddc8c6..a0e6a9bdb 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -919,6 +919,72 @@ describe('Kernel', () => { kernel2.getSystemSubclusterRoot('testSystemSubcluster'), ).toBeUndefined(); }); + + it('throws if persisted system subcluster has no vats', async () => { + const db = makeMapKernelDatabase(); + const systemSubclusterConfig = { + name: 'testSystemSubcluster', + config: { + bootstrap: 'testSystemSubcluster', + vats: { + testSystemSubcluster: { + sourceSpec: 'system-vat.js', + }, + }, + }, + }; + + // Create kernel with system subcluster + const kernel1 = await Kernel.make(mockPlatformServices, db, { + systemSubclusters: [systemSubclusterConfig], + }); + await kernel1.stop(); + + // Corrupt database: remove vats from the subcluster + const subclustersJson = db.kernelKVStore.get('subclusters'); + const subclusters = JSON.parse(subclustersJson ?? '[]'); + subclusters[0].vats = []; + db.kernelKVStore.set('subclusters', JSON.stringify(subclusters)); + + // Restart kernel - should throw + await expect( + Kernel.make(mockPlatformServices, db, { + systemSubclusters: [systemSubclusterConfig], + }), + ).rejects.toThrow('has no vats - database may be corrupted'); + }); + + it('throws if persisted system subcluster has no root object', async () => { + const db = makeMapKernelDatabase(); + const systemSubclusterConfig = { + name: 'testSystemSubcluster', + config: { + bootstrap: 'testSystemSubcluster', + vats: { + testSystemSubcluster: { + sourceSpec: 'system-vat.js', + }, + }, + }, + }; + + // Create kernel with system subcluster + const kernel1 = await Kernel.make(mockPlatformServices, db, { + systemSubclusters: [systemSubclusterConfig], + }); + await kernel1.stop(); + + // Corrupt database: delete the root object entry for the vat + // Root object is stored at: ${vatId}.c.o+0 + db.kernelKVStore.delete('v1.c.o+0'); + + // Restart kernel - should throw + await expect( + Kernel.make(mockPlatformServices, db, { + systemSubclusters: [systemSubclusterConfig], + }), + ).rejects.toThrow('has no root object - database may be corrupted'); + }); }); describe('revoke and isRevoked', () => { diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index c94f9f457..adfd5e0f1 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -299,16 +299,16 @@ export class Kernel { const bootstrapVatId = subcluster.vats[0]; if (!bootstrapVatId) { - continue; + throw new Error( + `System subcluster "${name}" has no vats - database may be corrupted`, + ); } const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); if (!rootKref) { - this.#logger.warn( - `System subcluster "${name}" has no root object, will relaunch`, + throw new Error( + `System subcluster "${name}" has no root object - database may be corrupted`, ); - this.#kernelStore.deleteSystemSubclusterMapping(name); - continue; } // Valid subcluster to restore - register kernelFacet on first valid one From 0f91b913ca1f2f9327b7ba6a8d448699c3c8d295 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:31:22 -0800 Subject: [PATCH 21/47] fix(omnium-gatherum): Restore controller vat from baggage on kernel restart The controller vat created a new PromiseKit on every initialization but only resolved it in bootstrap(). Since bootstrap() is not called during resuscitation (kernel restart), all caplet methods would hang. Fix by restoring kernelFacet from baggage and initializing the CapletController immediately in buildRootObject when available. Co-Authored-By: Claude Opus 4.5 --- .../src/vats/controller-vat.ts | 123 +++++++++++++----- 1 file changed, 90 insertions(+), 33 deletions(-) diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 64f1b9701..7e3d550ec 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -13,6 +13,7 @@ import type { CapletControllerFacet, LaunchResult, } from '../controllers/caplet/index.ts'; +import type { StorageAdapter } from '../controllers/storage/types.ts'; /** * Vat powers provided to the controller vat. @@ -37,6 +38,52 @@ type BootstrapServices = { kernelFacet?: KernelFacet; }; +/** + * Initialize the CapletController with the given kernelFacet. + * + * @param options - Initialization options. + * @param options.kernelFacet - The kernel facet for kernel operations. + * @param options.storageAdapter - The storage adapter for persistence. + * @param options.vatLogger - Optional logger for the vat. + * @param options.resolve - Function to resolve the caplet facet promise. + * @param options.reject - Function to reject the caplet facet promise. + */ +async function initializeCapletController(options: { + kernelFacet: KernelFacet; + storageAdapter: StorageAdapter; + vatLogger: Logger | undefined; + resolve: (facet: CapletControllerFacet) => void; + reject: (error: unknown) => void; +}): Promise { + const { kernelFacet, storageAdapter, vatLogger, resolve, reject } = options; + + try { + const capletFacet = await CapletController.make( + { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + const result = await E(kernelFacet).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; + }, + terminateSubcluster: async (subclusterId: string): Promise => + E(kernelFacet).terminateSubcluster(subclusterId), + getVatRoot: async (krefString: string): Promise => + E(kernelFacet).getVatRoot(krefString), + }, + ); + resolve(capletFacet); + } catch (error) { + reject(error); + throw error; + } +} + /** * Controller vat for Omnium system services. * Hosts controllers with baggage-backed persistence. @@ -59,13 +106,33 @@ export function buildRootObject( // Create baggage-backed storage adapter const storageAdapter = makeBaggageStorageAdapter(baggage); - // Promise kit for the caplet controller facet, resolved/rejected in bootstrap() + // Promise kit for the caplet controller facet const { promise: capletFacetP, resolve: resolveCapletFacet, reject: rejectCapletFacet, }: PromiseKit = makePromiseKit(); + // Restore kernelFacet from baggage if available (for resuscitation) + const kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') + ? (baggage.get('kernelFacet') as KernelFacet) + : undefined; + + // If we have a persisted kernelFacet, initialize the controller immediately + if (kernelFacet) { + logger?.info('Restoring controller from baggage'); + // Fire-and-forget: the promise kit will be resolved when initialization completes + initializeCapletController({ + kernelFacet, + storageAdapter, + vatLogger, + resolve: resolveCapletFacet, + reject: rejectCapletFacet, + }).catch((error) => { + logger?.error('Failed to restore controller from baggage:', error); + }); + } + // Define delegating methods for caplet operations const capletMethods = defineMethods(capletFacetP, [ 'install', @@ -86,41 +153,31 @@ export function buildRootObject( _vats: unknown, services: BootstrapServices, ): Promise { + // Skip if already initialized from baggage (resuscitation case) + if (kernelFacet) { + logger?.info('Bootstrap called but already restored from baggage'); + return; + } + logger?.info('Bootstrap called'); - try { - const { kernelFacet } = services; - if (!kernelFacet) { - throw new Error('kernelFacet service is required'); - } - - // Initialize caplet controller with baggage-backed storage - const capletFacet = await CapletController.make( - { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, - { - adapter: storageAdapter, - launchSubcluster: async ( - config: ClusterConfig, - ): Promise => { - const result = await E(kernelFacet).launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootKref: result.rootKref, - }; - }, - terminateSubcluster: async (subclusterId: string): Promise => - E(kernelFacet).terminateSubcluster(subclusterId), - getVatRoot: async (krefString: string): Promise => - E(kernelFacet).getVatRoot(krefString), - }, - ); - resolveCapletFacet(capletFacet); - - logger?.info('Bootstrap complete'); - } catch (error) { - rejectCapletFacet(error); - throw error; + const { kernelFacet: newKernelFacet } = services; + if (!newKernelFacet) { + throw new Error('kernelFacet service is required'); } + + // Store in baggage for persistence across restarts + baggage.init('kernelFacet', newKernelFacet); + + await initializeCapletController({ + kernelFacet: newKernelFacet, + storageAdapter, + vatLogger, + resolve: resolveCapletFacet, + reject: rejectCapletFacet, + }); + + logger?.info('Bootstrap complete'); }, ...capletMethods, From ada3ebb6ccb7afaf6528fd40546ff8efa83d58f7 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:38:18 -0800 Subject: [PATCH 22/47] chore(cli): Add TODO for process.env.NODE_ENV injection Add a TODO comment noting that the define block should be replaced with a process shim in VatSupervisor workerEndowments. Co-Authored-By: Claude Opus 4.5 --- packages/cli/src/vite/vat-bundler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/vite/vat-bundler.ts b/packages/cli/src/vite/vat-bundler.ts index 4538c44d6..e35cc174a 100644 --- a/packages/cli/src/vite/vat-bundler.ts +++ b/packages/cli/src/vite/vat-bundler.ts @@ -20,7 +20,9 @@ export async function bundleVat(sourcePath: string): Promise { const result = await build({ configFile: false, logLevel: 'silent', - // Replace process.env references since they don't exist in SES vat environment + // TODO: Remove this define block and add a process shim to VatSupervisor + // workerEndowments instead. This injects into ALL bundles but is only needed + // for libraries like immer that check process.env.NODE_ENV. define: { 'process.env.NODE_ENV': JSON.stringify('production'), }, From 4b3db2cbd2fa3e60f5a8efa0844d00b70e5d026b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:24:34 -0800 Subject: [PATCH 23/47] test(nodejs): Fix type errors in system-vat.ts --- packages/nodejs/test/vats/system-vat.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/nodejs/test/vats/system-vat.ts b/packages/nodejs/test/vats/system-vat.ts index 967b7b526..cbd4cea7f 100644 --- a/packages/nodejs/test/vats/system-vat.ts +++ b/packages/nodejs/test/vats/system-vat.ts @@ -86,7 +86,8 @@ export function buildRootObject( * @returns The kernel status. */ async getKernelStatus(): Promise { - return E(kernelFacet).getStatus(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return E(kernelFacet!).getStatus(); }, /** @@ -95,7 +96,8 @@ export function buildRootObject( * @returns The list of subclusters. */ async getSubclusters(): Promise { - return E(kernelFacet).getSubclusters(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return E(kernelFacet!).getSubclusters(); }, /** @@ -107,7 +109,8 @@ export function buildRootObject( async launchSubcluster( config: ClusterConfig, ): Promise { - return E(kernelFacet).launchSubcluster(config); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return E(kernelFacet!).launchSubcluster(config); }, /** @@ -117,7 +120,8 @@ export function buildRootObject( * @returns A promise that resolves when the subcluster is terminated. */ async terminateSubcluster(subclusterId: string): Promise { - return E(kernelFacet).terminateSubcluster(subclusterId); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return E(kernelFacet!).terminateSubcluster(subclusterId); }, /** From 4627815e5d22eecddbd534721faae049cee8a6c1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:45:32 -0800 Subject: [PATCH 24/47] fix(ocap-kernel,omnium-gatherum): Address PR review feedback - Fix baggage adapter to use actual delete() instead of null tombstones - Rename root to rootObject in KernelFacetLaunchResult for clarity - Add subclusterId format validation in Kernel.getSubcluster() - Add duplicate system subcluster name detection at kernel init - Clarify comment in controller-vat resuscitation path Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/Kernel.test.ts | 16 ++++++++++++-- packages/ocap-kernel/src/Kernel.ts | 12 ++++++++++- packages/ocap-kernel/src/kernel-facet.test.ts | 8 +++---- packages/ocap-kernel/src/kernel-facet.ts | 6 +++--- .../src/vats/controller-vat.ts | 2 +- .../src/vats/storage/baggage-adapter.test.ts | 21 +++++++++++-------- .../src/vats/storage/baggage-adapter.ts | 11 +++------- 7 files changed, 48 insertions(+), 28 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index a0e6a9bdb..4220fce40 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -353,7 +353,18 @@ describe('Kernel', () => { mockPlatformServices, mockKernelDatabase, ); - expect(kernel.getSubcluster('non-existent')).toBeUndefined(); + // Use valid subcluster ID format (s + number) that doesn't exist + expect(kernel.getSubcluster('s999')).toBeUndefined(); + }); + + it('throws for invalid subcluster ID format', async () => { + const kernel = await Kernel.make( + mockPlatformServices, + mockKernelDatabase, + ); + expect(() => kernel.getSubcluster('non-existent')).toThrow( + 'Invalid subcluster ID: non-existent', + ); }); }); @@ -371,7 +382,8 @@ describe('Kernel', () => { const subclusterId = firstSubcluster?.id as string; expect(subclusterId).toBeDefined(); expect(kernel.isVatInSubcluster('v1', subclusterId)).toBe(true); - expect(kernel.isVatInSubcluster('v1', 'other-subcluster')).toBe(false); + // Use valid subcluster ID format (s + number) that doesn't match + expect(kernel.isVatInSubcluster('v1', 's999')).toBe(false); }); }); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index adfd5e0f1..c1cd5079c 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -26,7 +26,7 @@ import type { EndpointHandle, SystemSubclusterConfig, } from './types.ts'; -import { isVatId, isRemoteId } from './types.ts'; +import { isVatId, isRemoteId, isSubclusterId } from './types.ts'; import { SubclusterManager } from './vats/SubclusterManager.ts'; import type { VatHandle } from './vats/VatHandle.ts'; import { VatManager } from './vats/VatManager.ts'; @@ -223,6 +223,12 @@ export class Kernel { ): Promise { const configs = systemSubclusterConfigs ?? []; + // Validate no duplicate system subcluster names + const names = new Set(configs.map((config) => config.name)); + if (names.size !== configs.length) { + throw new Error('Duplicate system subcluster names in config'); + } + // Set up the remote message handler this.#remoteManager.setMessageHandler( async (from: string, message: string) => @@ -487,8 +493,12 @@ export class Kernel { * * @param subclusterId - The id of the subcluster. * @returns The subcluster, or undefined if not found. + * @throws If subclusterId is not a valid subcluster ID format. */ getSubcluster(subclusterId: string): Subcluster | undefined { + if (!isSubclusterId(subclusterId)) { + throw new Error(`Invalid subcluster ID: ${String(subclusterId)}`); + } return this.#subclusterManager.getSubcluster(subclusterId); } diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index d13524ecd..4772f5d0c 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -60,11 +60,11 @@ describe('makeKernelFacet', () => { expect(deps.launchSubcluster).toHaveBeenCalledWith(config); }); - it('returns subclusterId and root as slot value', async () => { + it('returns subclusterId and rootObject as slot value', async () => { const facet = makeKernelFacet(deps) as { launchSubcluster: ( config: ClusterConfig, - ) => Promise<{ subclusterId: string; root: SlotValue }>; + ) => Promise<{ subclusterId: string; rootObject: SlotValue }>; }; const config: ClusterConfig = { bootstrap: 'myVat', @@ -74,8 +74,8 @@ describe('makeKernelFacet', () => { const result = await facet.launchSubcluster(config); expect(result.subclusterId).toBe('s1'); - // The root is a slot value (remotable) that carries the kref - expect(krefOf(result.root)).toBe('ko1'); + // The rootObject is a slot value (remotable) that carries the kref + expect(krefOf(result.rootObject)).toBe('ko1'); }); }); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 5e6f15483..8a0833465 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -30,10 +30,10 @@ export type KernelFacetLaunchResult = { * The root object as a slot value (becomes a presence when marshalled). * Use this directly with E() for immediate operations. */ - root: SlotValue; + rootObject: SlotValue; /** * The root kref string for storage purposes. - * Store this value to restore the presence after restart using getSubclusterRoot(). + * Store this value to restore the presence after restart using getVatRoot(). */ rootKref: string; }; @@ -108,7 +108,7 @@ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { const result = await launchSubcluster(config); return { subclusterId: result.subclusterId, - root: kslot(result.bootstrapRootKref, 'vatRoot'), + rootObject: kslot(result.bootstrapRootKref, 'vatRoot'), rootKref: result.bootstrapRootKref, }; }, diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 7e3d550ec..962964698 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -121,7 +121,7 @@ export function buildRootObject( // If we have a persisted kernelFacet, initialize the controller immediately if (kernelFacet) { logger?.info('Restoring controller from baggage'); - // Fire-and-forget: the promise kit will be resolved when initialization completes + // Fire-and-forget: the promise kit will be resolved/rejected when initialization completes initializeCapletController({ kernelFacet, storageAdapter, diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts index 22a33e7ff..247d90d8c 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -24,6 +24,9 @@ function makeMockBaggage() { } store.set(key, value); }), + delete: vi.fn((key: string) => { + store.delete(key); + }), _store: store, // For test inspection }; } @@ -49,15 +52,14 @@ describe('makeBaggageStorageAdapter', () => { expect(result).toStrictEqual({ foo: 'bar' }); }); - it('returns undefined for deleted key (null tombstone)', async () => { + it('returns undefined for deleted key', async () => { await adapter.set('to-delete', { data: 'test' }); await adapter.delete('to-delete'); - // Baggage still has the key but value is null - expect(baggage._store.has('to-delete')).toBe(true); - expect(baggage._store.get('to-delete')).toBeNull(); + // Baggage should no longer have the key + expect(baggage._store.has('to-delete')).toBe(false); - // Adapter should return undefined, not null + // Adapter should return undefined const result = await adapter.get('to-delete'); expect(result).toBeUndefined(); }); @@ -95,10 +97,10 @@ describe('makeBaggageStorageAdapter', () => { await adapter.set('reused-key', { original: true }); expect(await adapter.keys()).toContain('reused-key'); - // Delete it (creates null tombstone but removes from tracking) + // Delete it await adapter.delete('reused-key'); expect(await adapter.keys()).not.toContain('reused-key'); - expect(baggage._store.has('reused-key')).toBe(true); // tombstone exists + expect(baggage._store.has('reused-key')).toBe(false); // Set again - should re-add to tracking await adapter.set('reused-key', { restored: true }); @@ -108,11 +110,12 @@ describe('makeBaggageStorageAdapter', () => { }); describe('delete', () => { - it('sets value to null and removes from key list', async () => { + it('removes key from baggage and key list', async () => { await adapter.set('to-delete', { data: 'test' }); await adapter.delete('to-delete'); - expect(baggage._store.get('to-delete')).toBeNull(); + expect(baggage._store.has('to-delete')).toBe(false); + expect(baggage.delete).toHaveBeenCalledWith('to-delete'); const keys = await adapter.keys(); expect(keys).not.toContain('to-delete'); }); diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts index 361fe5649..451fb8523 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -11,6 +11,7 @@ export type Baggage = { get: (key: string) => unknown; init: (key: string, value: unknown) => void; set: (key: string, value: unknown) => void; + delete: (key: string) => void; }; const KEYS_KEY = '__storage_keys__'; @@ -55,12 +56,7 @@ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { return harden({ async get(key: string): Promise { if (baggage.has(key)) { - const value = baggage.get(key); - // Return undefined for null tombstones (deleted keys) - if (value === null) { - return undefined; - } - return value as Value; + return baggage.get(key) as Value; } return undefined; }, @@ -80,9 +76,8 @@ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { }, async delete(key: string): Promise { - // Baggage doesn't support true deletion, so we set to null marker if (baggage.has(key)) { - baggage.set(key, harden(null)); + baggage.delete(key); const keys = getKeys(); keys.delete(key); saveKeys(keys); From 15dcf8cb3f48cc0c11798b4f5b5dd8afeeccfdfe Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:50:18 -0800 Subject: [PATCH 25/47] fix(nodejs): Increase connection rate limit in queue management test Async kernel service invocations can cause multiple concurrent connection attempts when processing many messages, which triggers the default rate limiter. Increase maxConnectionAttemptsPerMinute to avoid interference with the queue limit test. Co-Authored-By: Claude Opus 4.5 --- packages/nodejs/test/e2e/remote-comms.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nodejs/test/e2e/remote-comms.test.ts b/packages/nodejs/test/e2e/remote-comms.test.ts index 772905244..ae236db29 100644 --- a/packages/nodejs/test/e2e/remote-comms.test.ts +++ b/packages/nodejs/test/e2e/remote-comms.test.ts @@ -524,14 +524,16 @@ describe.sequential('Remote Communications E2E', () => { it( 'rejects new messages when queue reaches MAX_QUEUE limit', async () => { - // Use high rate limit to avoid rate limiting interference with queue limit test + // Use high rate limits to avoid rate limiting interference with queue limit test + // maxConnectionAttemptsPerMinute is needed because async kernel service invocations + // can cause multiple concurrent connection attempts when processing many messages const { aliceRef, bobURL } = await setupAliceAndBob( kernel1, kernel2, kernelStore1, kernelStore2, testRelays, - { maxMessagesPerSecond: 500 }, + { maxMessagesPerSecond: 500, maxConnectionAttemptsPerMinute: 500 }, ); await sendRemoteMessage(kernel1, aliceRef, bobURL, 'hello', ['Alice']); From 6672eb8ceb66d2061dd0c9fa93c4b9df1732c511 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:52:46 -0800 Subject: [PATCH 26/47] refactor: Consolidate KernelFacade into KernelFacet Replace the KernelFacade type and makeKernelFacade factory (from kernel-browser-runtime) with KernelFacet and makeKernelFacet (from ocap-kernel). The kernel facet is now a thin delegate layer over the kernel, with the only additions being ping() and getVatRoot(). Key changes: - Add missing methods to KernelFacet (ping, pingVat, getSystemSubclusterRoot, reset, queueMessage) - Add Kernel.provideFacet() for idempotent facet creation, replacing the boolean flag and #ensureKernelFacetRegistered() - Move throw-on-missing logic for getSystemSubclusterRoot into Kernel - Rename bootstrapRootKref to rootKref in SubclusterLaunchResult - Remove KernelFacade type, makeKernelFacade, KernelFacetLaunchResult, and LaunchResult from kernel-browser-runtime - Update all consumers (omnium-gatherum, extension) to use KernelFacet Co-Authored-By: Claude Opus 4.6 --- packages/extension/package.json | 1 + packages/extension/src/global.d.ts | 4 +- .../src/background-captp.ts | 9 +- packages/kernel-browser-runtime/src/index.ts | 1 - .../captp/captp.integration.test.ts | 40 ++-- .../src/kernel-worker/captp/index.ts | 2 - .../kernel-worker/captp/kernel-captp.test.ts | 19 +- .../src/kernel-worker/captp/kernel-captp.ts | 10 +- .../kernel-worker/captp/kernel-facade.test.ts | 196 ------------------ .../src/kernel-worker/captp/kernel-facade.ts | 59 ------ .../rpc-handlers/launch-subcluster.test.ts | 8 +- .../src/rpc-handlers/launch-subcluster.ts | 6 +- packages/kernel-browser-runtime/src/types.ts | 28 --- packages/ocap-kernel/src/Kernel.test.ts | 10 +- packages/ocap-kernel/src/Kernel.ts | 50 +++-- packages/ocap-kernel/src/index.test.ts | 1 + packages/ocap-kernel/src/index.ts | 4 +- packages/ocap-kernel/src/kernel-facet.test.ts | 142 ++++++++++--- packages/ocap-kernel/src/kernel-facet.ts | 134 +++++++----- packages/ocap-kernel/src/types.ts | 2 +- .../src/vats/SubclusterManager.test.ts | 4 +- .../ocap-kernel/src/vats/SubclusterManager.ts | 25 +-- packages/omnium-gatherum/src/background.ts | 14 +- .../controllers/caplet/caplet-controller.ts | 1 - packages/omnium-gatherum/src/global.d.ts | 4 +- .../src/vats/controller-vat.ts | 7 +- yarn.lock | 1 + 27 files changed, 320 insertions(+), 462 deletions(-) delete mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts delete mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index ccff569f1..238284917 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -49,6 +49,7 @@ "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index f63d2a3a6..393e81ac4 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -1,4 +1,4 @@ -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { KernelFacet } from '@metamask/ocap-kernel'; // Type declarations for kernel dev console API. declare global { @@ -16,7 +16,7 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var kernel: KernelFacade | Promise; + var kernel: KernelFacet | Promise; } export {}; diff --git a/packages/kernel-browser-runtime/src/background-captp.ts b/packages/kernel-browser-runtime/src/background-captp.ts index ffc431285..a8258aaae 100644 --- a/packages/kernel-browser-runtime/src/background-captp.ts +++ b/packages/kernel-browser-runtime/src/background-captp.ts @@ -1,8 +1,9 @@ import { makeCapTP } from '@endo/captp'; import type { JsonRpcMessage, JsonRpcCall } from '@metamask/kernel-utils'; +import type { KernelFacet } from '@metamask/ocap-kernel'; import type { JsonRpcNotification } from '@metamask/utils'; -import type { CapTPMessage, KernelFacade } from './types.ts'; +import type { CapTPMessage } from './types.ts'; export type { CapTPMessage }; @@ -79,12 +80,12 @@ export type BackgroundCapTP = { dispatch: (message: CapTPMessage) => boolean; /** - * Get the remote kernel facade. + * Get the remote kernel facet. * This is how the background calls kernel methods using E(). * - * @returns A promise for the kernel facade remote presence. + * @returns A promise for the kernel facet remote presence. */ - getKernel: () => Promise; + getKernel: () => Promise; /** * Abort the CapTP connection. diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 4c10590e3..ba2dea2f8 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -11,7 +11,6 @@ export * from './makeIframeVatWorker.ts'; export * from './PlatformServicesClient.ts'; export * from './PlatformServicesServer.ts'; export * from './utils/index.ts'; -export type { KernelFacade } from './types.ts'; export { makeBackgroundCapTP, isCapTPNotification, diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts index 0e3fe0cf0..8a232bff2 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts @@ -1,5 +1,6 @@ import { E } from '@endo/eventual-send'; import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; +import { makeKernelFacet } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { makeKernelCapTP } from './kernel-captp.ts'; @@ -10,7 +11,7 @@ import type { CapTPMessage } from '../../background-captp.ts'; * Integration tests for CapTP communication between background and kernel endpoints. * * These tests validate that the two CapTP endpoints can communicate correctly - * and that E() works properly with the kernel facade remote presence. + * and that E() works properly with the kernel facet remote presence. */ describe('CapTP Integration', () => { let mockKernel: Kernel; @@ -20,31 +21,42 @@ describe('CapTP Integration', () => { beforeEach(() => { // Create mock kernel with method implementations mockKernel = { + getStatus: vi.fn().mockResolvedValue({ + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: ['sc1'], + remoteComms: false, + }), + getSubcluster: vi.fn().mockReturnValue(undefined), + getSubclusters: vi.fn().mockReturnValue([]), + getSystemSubclusterRoot: vi.fn().mockReturnValue('ko99'), launchSubcluster: vi.fn().mockResolvedValue({ subclusterId: 'sc1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: { body: '#{"result":"ok"}', slots: [], }, }), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), + pingVat: vi.fn().mockResolvedValue('pong'), queueMessage: vi.fn().mockResolvedValue({ body: '#{"result":"message-sent"}', slots: [], }), - getStatus: vi.fn().mockResolvedValue({ - vats: [{ id: 'v1', name: 'test-vat' }], - subclusters: ['sc1'], - remoteComms: false, - }), - pingVat: vi.fn().mockResolvedValue('pong'), + reloadSubcluster: vi.fn().mockResolvedValue({ id: 'sc1', vats: [] }), + reset: vi.fn().mockResolvedValue(undefined), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + provideFacet: vi.fn(), } as unknown as Kernel; + // Wire up provideFacet to return a real facet backed by the mock kernel + vi.mocked(mockKernel.provideFacet).mockReturnValue( + makeKernelFacet(mockKernel), + ); + // Wire up CapTP endpoints to dispatch messages synchronously to each other // This simulates direct message passing for testing - // Kernel-side: exposes facade as bootstrap + // Kernel-side: exposes facet as bootstrap kernelCapTP = makeKernelCapTP({ kernel: mockKernel, send: (message: CapTPMessage) => { @@ -64,7 +76,7 @@ describe('CapTP Integration', () => { describe('bootstrap', () => { it('background can get kernel remote presence via getKernel', async () => { - // Request the kernel facade - with synchronous dispatch, this resolves immediately + // Request the kernel facet - with synchronous dispatch, this resolves immediately const kernel = await backgroundCapTP.getKernel(); expect(kernel).toBeDefined(); }); @@ -115,10 +127,14 @@ describe('CapTP Integration', () => { // Call launchSubcluster via E() const result = await E(kernel).launchSubcluster(config); - // The kernel facade now returns LaunchResult instead of CapData + // The kernel facet delegates to the kernel's launchSubcluster expect(result).toStrictEqual({ subclusterId: 'sc1', rootKref: 'ko1', + bootstrapResult: { + body: '#{"result":"ok"}', + slots: [], + }, }); expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts index 6e3ee7053..8c441ab60 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts @@ -3,5 +3,3 @@ export { type KernelCapTP, type KernelCapTPOptions, } from './kernel-captp.ts'; - -export { makeKernelFacade, type KernelFacade } from './kernel-facade.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts index fbd1eb0d2..08bc880dc 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -1,11 +1,28 @@ import type { Kernel } from '@metamask/ocap-kernel'; +import { makeKernelFacet } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { makeKernelCapTP } from './kernel-captp.ts'; import type { CapTPMessage } from '../../types.ts'; describe('makeKernelCapTP', () => { - const mockKernel: Kernel = {} as unknown as Kernel; + const mockKernel = { + getStatus: vi.fn(), + getSubcluster: vi.fn(), + getSubclusters: vi.fn(), + getSystemSubclusterRoot: vi.fn(), + launchSubcluster: vi.fn(), + pingVat: vi.fn(), + queueMessage: vi.fn(), + reloadSubcluster: vi.fn(), + reset: vi.fn(), + terminateSubcluster: vi.fn(), + provideFacet: vi.fn(), + } as unknown as Kernel; + + vi.mocked(mockKernel.provideFacet).mockReturnValue( + makeKernelFacet(mockKernel), + ); let sendMock: (message: CapTPMessage) => void; beforeEach(() => { diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts index 16587a100..1aa9d647f 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts @@ -1,7 +1,6 @@ import { makeCapTP } from '@endo/captp'; import type { Kernel } from '@metamask/ocap-kernel'; -import { makeKernelFacade } from './kernel-facade.ts'; import type { CapTPMessage } from '../../types.ts'; /** @@ -44,7 +43,7 @@ export type KernelCapTP = { /** * Create a CapTP endpoint for the kernel. * - * This sets up a CapTP connection that exposes the kernel facade as the + * This sets up a CapTP connection that exposes the kernel facet as the * bootstrap object. The background can then use `E(kernel).method()` to * call kernel methods. * @@ -54,11 +53,8 @@ export type KernelCapTP = { export function makeKernelCapTP(options: KernelCapTPOptions): KernelCapTP { const { kernel, send } = options; - // Create the kernel facade that will be exposed to the background - const kernelFacade = makeKernelFacade(kernel); - - // Create the CapTP endpoint - const { dispatch, abort } = makeCapTP('kernel', send, kernelFacade); + // Create the CapTP endpoint with the kernel facet as the bootstrap object + const { dispatch, abort } = makeCapTP('kernel', send, kernel.provideFacet()); return harden({ dispatch, diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts deleted file mode 100644 index cdaf77703..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { - ClusterConfig, - Kernel, - KernelStatus, - KRef, - VatId, -} from '@metamask/ocap-kernel'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeKernelFacade } from './kernel-facade.ts'; -import type { KernelFacade } from './kernel-facade.ts'; - -const makeClusterConfig = (): ClusterConfig => ({ - bootstrap: 'test-vat', - vats: { - 'test-vat': { bundleSpec: 'test' }, - }, -}); - -describe('makeKernelFacade', () => { - let mockKernel: Kernel; - let facade: KernelFacade; - - beforeEach(() => { - mockKernel = { - launchSubcluster: vi.fn().mockResolvedValue({ - subclusterId: 'sc1', - bootstrapRootKref: 'ko1', - }), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), - queueMessage: vi.fn().mockResolvedValue({ - body: '#{"result":"success"}', - slots: [], - }), - getStatus: vi.fn().mockResolvedValue({ - vats: [], - subclusters: [], - remoteComms: false, - }), - pingVat: vi.fn().mockResolvedValue('pong'), - } as unknown as Kernel; - - facade = makeKernelFacade(mockKernel); - }); - - describe('ping', () => { - it('returns "pong"', async () => { - const result = await facade.ping(); - expect(result).toBe('pong'); - }); - }); - - describe('launchSubcluster', () => { - it('delegates to kernel with correct arguments', async () => { - const config: ClusterConfig = makeClusterConfig(); - - await facade.launchSubcluster(config); - - expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); - expect(mockKernel.launchSubcluster).toHaveBeenCalledTimes(1); - }); - - it('returns result with subclusterId and rootKref from kernel', async () => { - const kernelResult = { - subclusterId: 's1', - bootstrapRootKref: 'ko1', - bootstrapResult: { body: '#null', slots: [] }, - }; - vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( - kernelResult, - ); - - const config: ClusterConfig = makeClusterConfig(); - - const result = await facade.launchSubcluster(config); - - expect(result).toStrictEqual({ - subclusterId: 's1', - rootKref: 'ko1', - }); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Launch failed'); - vi.mocked(mockKernel.launchSubcluster).mockRejectedValueOnce(error); - const config: ClusterConfig = makeClusterConfig(); - - await expect(facade.launchSubcluster(config)).rejects.toThrow(error); - }); - }); - - describe('terminateSubcluster', () => { - it('delegates to kernel with correct arguments', async () => { - const subclusterId = 'sc1'; - - await facade.terminateSubcluster(subclusterId); - - expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith(subclusterId); - expect(mockKernel.terminateSubcluster).toHaveBeenCalledTimes(1); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Terminate failed'); - vi.mocked(mockKernel.terminateSubcluster).mockRejectedValueOnce(error); - - await expect(facade.terminateSubcluster('sc1')).rejects.toThrow(error); - }); - }); - - describe('queueMessage', () => { - it('delegates to kernel with correct arguments', async () => { - const target: KRef = 'ko1'; - const method = 'doSomething'; - const args = ['arg1', { nested: 'value' }]; - - await facade.queueMessage(target, method, args); - - expect(mockKernel.queueMessage).toHaveBeenCalledWith( - target, - method, - args, - ); - expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); - }); - - it('returns result from kernel', async () => { - const expectedResult = { body: '#{"answer":42}', slots: [] }; - vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); - - const result = await facade.queueMessage('ko1', 'compute', []); - expect(result).toStrictEqual(expectedResult); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Queue message failed'); - vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); - - await expect(facade.queueMessage('ko1', 'method', [])).rejects.toThrow( - error, - ); - }); - }); - - describe('getStatus', () => { - it('delegates to kernel', async () => { - await facade.getStatus(); - - expect(mockKernel.getStatus).toHaveBeenCalled(); - expect(mockKernel.getStatus).toHaveBeenCalledTimes(1); - }); - - it('returns status from kernel', async () => { - const expectedStatus: KernelStatus = { - vats: [], - subclusters: [], - remoteComms: { isInitialized: false }, - }; - vi.mocked(mockKernel.getStatus).mockResolvedValueOnce(expectedStatus); - - const result = await facade.getStatus(); - expect(result).toStrictEqual(expectedStatus); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Get status failed'); - vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); - - await expect(facade.getStatus()).rejects.toThrow(error); - }); - }); - - describe('pingVat', () => { - it('delegates to kernel with correct vatId', async () => { - const vatId: VatId = 'v1'; - - await facade.pingVat(vatId); - - expect(mockKernel.pingVat).toHaveBeenCalledWith(vatId); - expect(mockKernel.pingVat).toHaveBeenCalledTimes(1); - }); - - it('returns result from kernel', async () => { - vi.mocked(mockKernel.pingVat).mockResolvedValueOnce('pong'); - - const result = await facade.pingVat('v1'); - expect(result).toBe('pong'); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Ping vat failed'); - vi.mocked(mockKernel.pingVat).mockRejectedValueOnce(error); - - await expect(facade.pingVat('v1')).rejects.toThrow(error); - }); - }); -}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts deleted file mode 100644 index 48786b0f2..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; - -import type { KernelFacade, LaunchResult } from '../../types.ts'; - -export type { KernelFacade } from '../../types.ts'; - -/** - * Create the kernel facade exo that exposes kernel methods via CapTP. - * - * @param kernel - The kernel instance to wrap. - * @returns The kernel facade exo. - */ -export function makeKernelFacade(kernel: Kernel): KernelFacade { - return makeDefaultExo('KernelFacade', { - ping: async () => 'pong' as const, - - launchSubcluster: async (config: ClusterConfig): Promise => { - const { subclusterId, bootstrapRootKref } = - await kernel.launchSubcluster(config); - return { subclusterId, rootKref: bootstrapRootKref }; - }, - - terminateSubcluster: async (subclusterId: string) => { - return kernel.terminateSubcluster(subclusterId); - }, - - queueMessage: async (target: KRef, method: string, args: unknown[]) => { - return kernel.queueMessage(target, method, args); - }, - - getStatus: async () => { - return kernel.getStatus(); - }, - - pingVat: async (vatId: VatId) => { - return kernel.pingVat(vatId); - }, - - getVatRoot: async (krefString: string) => { - // Return wrapped kref for future CapTP marshalling to presence - // TODO: Enable custom CapTP marshalling tables to convert this to a presence - return { kref: krefString }; - }, - - getSystemSubclusterRoot: async (name: string) => { - const rootKref = kernel.getSystemSubclusterRoot(name); - if (!rootKref) { - throw new Error(`System subcluster "${name}" not found`); - } - return { kref: rootKref }; - }, - - reset: async () => { - return kernel.reset(); - }, - }); -} -harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts index aa60f21b4..7b0af761e 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts @@ -6,7 +6,7 @@ describe('launchSubclusterHandler', () => { it('calls kernel.launchSubcluster with the provided config', async () => { const mockResult = { subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: { body: '#null', slots: [] }, }; const mockKernel = { @@ -28,7 +28,7 @@ describe('launchSubclusterHandler', () => { it('returns the result from kernel.launchSubcluster', async () => { const mockResult = { subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: { body: '#{"result":"ok"}', slots: [] }, }; const mockKernel = { @@ -50,7 +50,7 @@ describe('launchSubclusterHandler', () => { it('converts undefined bootstrapResult to null for JSON compatibility', async () => { const mockResult = { subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: undefined, }; const mockKernel = { @@ -68,7 +68,7 @@ describe('launchSubclusterHandler', () => { ); expect(result).toStrictEqual({ subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: null, }); }); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts index c899b3dcd..cb85241a2 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts @@ -15,13 +15,13 @@ import { */ type LaunchSubclusterRpcResult = { subclusterId: string; - bootstrapRootKref: string; + rootKref: string; bootstrapResult: CapData | null; }; const LaunchSubclusterRpcResultStruct = structType({ subclusterId: string(), - bootstrapRootKref: string(), + rootKref: string(), bootstrapResult: nullable(CapDataStruct), }); @@ -55,7 +55,7 @@ export const launchSubclusterHandler: Handler< // Convert undefined to null for JSON compatibility return { subclusterId: result.subclusterId, - bootstrapRootKref: result.bootstrapRootKref, + rootKref: result.rootKref, bootstrapResult: result.bootstrapResult ?? null, }; }, diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index b6027d785..a9d46c374 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -1,34 +1,6 @@ -import type { Kernel, ClusterConfig } from '@metamask/ocap-kernel'; import type { Json } from '@metamask/utils'; /** * A CapTP message that can be sent over the wire. */ export type CapTPMessage = Record; - -/** - * Result of launching a subcluster. - * - * The rootKref contains the kref string for the bootstrap vat's root object. - */ -export type LaunchResult = { - subclusterId: string; - rootKref: string; -}; - -/** - * The kernel facade interface - methods exposed to userspace via CapTP. - * - * This is the remote presence type that the background receives from the kernel. - */ -export type KernelFacade = { - ping: () => Promise<'pong'>; - launchSubcluster: (config: ClusterConfig) => Promise; - terminateSubcluster: Kernel['terminateSubcluster']; - queueMessage: Kernel['queueMessage']; - getStatus: Kernel['getStatus']; - pingVat: Kernel['pingVat']; - getVatRoot: (krefString: string) => Promise; - getSystemSubclusterRoot: (name: string) => Promise<{ kref: string }>; - reset: Kernel['reset']; -}; diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 4220fce40..24ccdbd68 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -289,7 +289,7 @@ describe('Kernel', () => { expect(result).toStrictEqual({ subclusterId: 's1', bootstrapResult: { body: '{"result":"ok"}', slots: [] }, - bootstrapRootKref: expect.stringMatching(/^ko\d+$/u), + rootKref: expect.stringMatching(/^ko\d+$/u), }); }); }); @@ -860,9 +860,9 @@ describe('Kernel', () => { await kernel.reset(); // Verify system subcluster roots are cleared - expect( + expect(() => kernel.getSystemSubclusterRoot('testSystemSubcluster'), - ).toBeUndefined(); + ).toThrow('System subcluster "testSystemSubcluster" not found'); }); it('logs an error if resetting the kernel state fails', async () => { @@ -927,9 +927,9 @@ describe('Kernel', () => { expect(makeVatHandleMock).not.toHaveBeenCalled(); expect(kernel2.getSubclusters()).toHaveLength(0); expect(kernel2.getVatIds()).toStrictEqual([]); - expect( + expect(() => kernel2.getSystemSubclusterRoot('testSystemSubcluster'), - ).toBeUndefined(); + ).toThrow('System subcluster "testSystemSubcluster" not found'); }); it('throws if persisted system subcluster has no vats', async () => { diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index c1cd5079c..98b0b2ad4 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -3,6 +3,7 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { Logger } from '@metamask/logger'; import { makeKernelFacet } from './kernel-facet.ts'; +import type { KernelFacet } from './kernel-facet.ts'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; @@ -54,9 +55,6 @@ export class Kernel { /** Stores bootstrap root krefs of launched system subclusters */ readonly #systemSubclusterRoots: Map = new Map(); - /** Whether the kernel facet has been registered */ - #kernelFacetRegistered = false; - /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -317,9 +315,9 @@ export class Kernel { ); } - // Valid subcluster to restore - register kernelFacet on first valid one + // Register kernelFacet on first valid persisted subcluster if (!hasValidPersistedSubclusters) { - this.#ensureKernelFacetRegistered(); + this.provideFacet(); hasValidPersistedSubclusters = true; } @@ -348,11 +346,11 @@ export class Kernel { } // Ensure kernelFacet is registered for new system subclusters - this.#ensureKernelFacetRegistered(); + this.provideFacet(); for (const { name, config } of newConfigs) { const result = await this.launchSubcluster(config); - this.#systemSubclusterRoots.set(name, result.bootstrapRootKref); + this.#systemSubclusterRoots.set(name, result.rootKref); // Persist the mapping this.#kernelStore.setSystemSubclusterMapping(name, result.subclusterId); @@ -362,27 +360,34 @@ export class Kernel { } /** - * Register the kernel facet as a kernel service for system subclusters. - * This is idempotent - calling it multiple times has no effect after the first. + * Provide the kernel facet, creating and registering it as a kernel service + * if it doesn't already exist. + * + * @returns The kernel facet. */ - #ensureKernelFacetRegistered(): void { - if (this.#kernelFacetRegistered) { - return; + provideFacet(): KernelFacet { + const existing = this.#kernelServiceManager.getKernelService('kernelFacet'); + if (existing) { + return existing.service as KernelFacet; } const kernelFacet = makeKernelFacet({ - launchSubcluster: this.launchSubcluster.bind(this), - terminateSubcluster: this.terminateSubcluster.bind(this), - reloadSubcluster: this.reloadSubcluster.bind(this), + getStatus: this.getStatus.bind(this), getSubcluster: this.getSubcluster.bind(this), getSubclusters: this.getSubclusters.bind(this), - getStatus: this.getStatus.bind(this), + getSystemSubclusterRoot: this.getSystemSubclusterRoot.bind(this), + launchSubcluster: this.launchSubcluster.bind(this), + pingVat: this.pingVat.bind(this), + queueMessage: this.queueMessage.bind(this), + reloadSubcluster: this.reloadSubcluster.bind(this), + reset: this.reset.bind(this), + terminateSubcluster: this.terminateSubcluster.bind(this), }); this.#kernelServiceManager.registerKernelServiceObject( 'kernelFacet', kernelFacet, ); - this.#kernelFacetRegistered = true; + return kernelFacet; } /** @@ -515,10 +520,15 @@ export class Kernel { * Get the bootstrap root kref of a system subcluster by name. * * @param name - The name of the system subcluster. - * @returns The bootstrap root kref or undefined if not found. + * @returns The bootstrap root kref. + * @throws If the system subcluster is not found. */ - getSystemSubclusterRoot(name: string): KRef | undefined { - return this.#systemSubclusterRoots.get(name); + getSystemSubclusterRoot(name: string): KRef { + const kref = this.#systemSubclusterRoots.get(name); + if (kref === undefined) { + throw new Error(`System subcluster "${name}" not found`); + } + return kref; } /** diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index 6f862268b..3194690a1 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -23,6 +23,7 @@ describe('index', () => { 'kser', 'kslot', 'kunser', + 'makeKernelFacet', 'makeKernelStore', 'mnemonicToSeed', 'parseRef', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 1bd3ddd81..3d448fd26 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -36,7 +36,9 @@ export { } from './types.ts'; export { kunser, kser, kslot, krefOf } from './liveslots/kernel-marshal.ts'; export type { SlotValue } from './liveslots/kernel-marshal.ts'; -export type { KernelFacet, KernelFacetLaunchResult } from './kernel-facet.ts'; +export type { KernelFacet } from './kernel-facet.ts'; +export { makeKernelFacet } from './kernel-facet.ts'; +export type { PingVatResult } from './rpc/index.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 4772f5d0c..51c5ae243 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -11,15 +11,10 @@ describe('makeKernelFacet', () => { beforeEach(() => { deps = { - launchSubcluster: vi.fn().mockResolvedValue({ - subclusterId: 's1', - bootstrapRootKref: 'ko1', - }), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), - reloadSubcluster: vi.fn().mockResolvedValue({ - id: 's2', - config: { bootstrap: 'test', vats: {} }, - vats: {}, + getStatus: vi.fn().mockResolvedValue({ + vats: [], + subclusters: [], + remoteComms: { isInitialized: false }, }), getSubcluster: vi.fn().mockReturnValue({ id: 's1', @@ -31,11 +26,23 @@ describe('makeKernelFacet', () => { .mockReturnValue([ { id: 's1', config: { bootstrap: 'test', vats: {} }, vats: {} }, ]), - getStatus: vi.fn().mockResolvedValue({ - vats: [], - subclusters: [], - remoteComms: { isInitialized: false }, + getSystemSubclusterRoot: vi.fn().mockReturnValue('ko99'), + launchSubcluster: vi.fn().mockResolvedValue({ + subclusterId: 's1', + rootKref: 'ko1', + }), + pingVat: vi.fn().mockResolvedValue({ alive: true }), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"ok"}', + slots: [], }), + reloadSubcluster: vi.fn().mockResolvedValue({ + id: 's2', + config: { bootstrap: 'test', vats: {} }, + vats: {}, + }), + reset: vi.fn().mockResolvedValue(undefined), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), }; }); @@ -46,7 +53,7 @@ describe('makeKernelFacet', () => { }); describe('launchSubcluster', () => { - it('calls the launchSubcluster dependency', async () => { + it('delegates to the launchSubcluster dependency', async () => { const facet = makeKernelFacet(deps) as { launchSubcluster: (config: ClusterConfig) => Promise; }; @@ -55,27 +62,13 @@ describe('makeKernelFacet', () => { vats: { myVat: { sourceSpec: 'test.js' } }, }; - await facet.launchSubcluster(config); - - expect(deps.launchSubcluster).toHaveBeenCalledWith(config); - }); - - it('returns subclusterId and rootObject as slot value', async () => { - const facet = makeKernelFacet(deps) as { - launchSubcluster: ( - config: ClusterConfig, - ) => Promise<{ subclusterId: string; rootObject: SlotValue }>; - }; - const config: ClusterConfig = { - bootstrap: 'myVat', - vats: { myVat: { sourceSpec: 'test.js' } }, - }; - const result = await facet.launchSubcluster(config); - expect(result.subclusterId).toBe('s1'); - // The rootObject is a slot value (remotable) that carries the kref - expect(krefOf(result.rootObject)).toBe('ko1'); + expect(deps.launchSubcluster).toHaveBeenCalledWith(config); + expect(result).toStrictEqual({ + subclusterId: 's1', + rootKref: 'ko1', + }); }); }); @@ -206,4 +199,87 @@ describe('makeKernelFacet', () => { expect(krefOf(result)).toBe('ko42'); }); }); + + describe('ping', () => { + it('returns "pong"', () => { + const facet = makeKernelFacet(deps) as { + ping: () => 'pong'; + }; + + expect(facet.ping()).toBe('pong'); + }); + }); + + describe('pingVat', () => { + it('delegates to the pingVat dependency', async () => { + const facet = makeKernelFacet(deps) as { + pingVat: (vatId: string) => Promise; + }; + + const result = await facet.pingVat('v1'); + + expect(result).toStrictEqual({ alive: true }); + expect(deps.pingVat).toHaveBeenCalledWith('v1'); + }); + }); + + describe('getSystemSubclusterRoot', () => { + it('returns the kref for a known system subcluster', () => { + const facet = makeKernelFacet(deps) as { + getSystemSubclusterRoot: (name: string) => string; + }; + + const result = facet.getSystemSubclusterRoot('my-system'); + + expect(result).toBe('ko99'); + expect(deps.getSystemSubclusterRoot).toHaveBeenCalledWith('my-system'); + }); + + it('propagates errors from the dependency', () => { + vi.mocked(deps.getSystemSubclusterRoot).mockImplementation(() => { + throw new Error('System subcluster "unknown" not found'); + }); + const facet = makeKernelFacet(deps) as { + getSystemSubclusterRoot: (name: string) => string; + }; + + expect(() => facet.getSystemSubclusterRoot('unknown')).toThrow( + 'System subcluster "unknown" not found', + ); + }); + }); + + describe('reset', () => { + it('delegates to the reset dependency', async () => { + const facet = makeKernelFacet(deps) as { + reset: () => Promise; + }; + + await facet.reset(); + + expect(deps.reset).toHaveBeenCalled(); + }); + }); + + describe('queueMessage', () => { + it('delegates to the queueMessage dependency', async () => { + const facet = makeKernelFacet(deps) as { + queueMessage: ( + target: string, + method: string, + args: unknown[], + ) => Promise; + }; + + const result = await facet.queueMessage('ko1', 'doThing', ['arg1']); + + expect(result).toStrictEqual({ + body: '#{"result":"ok"}', + slots: [], + }); + expect(deps.queueMessage).toHaveBeenCalledWith('ko1', 'doThing', [ + 'arg1', + ]); + }); + }); }); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 8a0833465..88bd90d0c 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -3,63 +3,39 @@ import { makeDefaultExo } from '@metamask/kernel-utils'; import type { Kernel } from './Kernel.ts'; import { kslot } from './liveslots/kernel-marshal.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import type { ClusterConfig, Subcluster, KernelStatus } from './types.ts'; +import type { PingVatResult } from './rpc/index.ts'; +import type { Subcluster, KernelStatus, KRef, VatId } from './types.ts'; /** * Dependencies required to create a kernel facet. */ export type KernelFacetDependencies = Pick< Kernel, - | 'launchSubcluster' - | 'terminateSubcluster' - | 'reloadSubcluster' + | 'getStatus' | 'getSubcluster' | 'getSubclusters' - | 'getStatus' + | 'getSystemSubclusterRoot' + | 'launchSubcluster' + | 'pingVat' + | 'queueMessage' + | 'reloadSubcluster' + | 'reset' + | 'terminateSubcluster' >; -/** - * Result of launching a subcluster via the kernel facet. - * Contains the root object as a slot value (which will become a presence) - * and the root kref string for storage purposes. - */ -export type KernelFacetLaunchResult = { - /** The ID of the launched subcluster. */ - subclusterId: string; - /** - * The root object as a slot value (becomes a presence when marshalled). - * Use this directly with E() for immediate operations. - */ - rootObject: SlotValue; - /** - * The root kref string for storage purposes. - * Store this value to restore the presence after restart using getVatRoot(). - */ - rootKref: string; -}; - /** * The kernel facet interface. * * This is the interface provided as a vatpower to the bootstrap vat of a * system vat. It enables privileged kernel operations. - * - * Derived from KernelFacetDependencies but with launchSubcluster overridden - * to return KernelFacetLaunchResult (root as SlotValue) instead of - * SubclusterLaunchResult (bootstrapRootKref as string). */ -export type KernelFacet = Omit< - KernelFacetDependencies, - 'logger' | 'launchSubcluster' -> & { +export type KernelFacet = KernelFacetDependencies & { /** - * Launch a dynamic subcluster. - * Returns root as a SlotValue (which becomes a presence when delivered). + * Ping the kernel. * - * @param config - Configuration for the subcluster. - * @returns A promise for the launch result containing subclusterId and root presence. + * @returns The string 'pong'. */ - launchSubcluster: (config: ClusterConfig) => Promise; + ping: () => 'pong'; /** * Convert a kref string to a slot value (presence). @@ -87,32 +63,67 @@ export type KernelFacet = Omit< */ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { const { - launchSubcluster, - terminateSubcluster, - reloadSubcluster, + getStatus, getSubcluster, getSubclusters, - getStatus, + getSystemSubclusterRoot, + launchSubcluster, + pingVat, + queueMessage, + reloadSubcluster, + reset, + terminateSubcluster, } = deps; const kernelFacet = makeDefaultExo('kernelFacet', { /** - * Launch a dynamic subcluster. + * Ping the kernel. * - * @param config - Configuration for the subcluster. - * @returns A promise for the launch result containing subclusterId and root presence. + * @returns The string 'pong'. */ - async launchSubcluster( - config: ClusterConfig, - ): Promise { - const result = await launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootObject: kslot(result.bootstrapRootKref, 'vatRoot'), - rootKref: result.bootstrapRootKref, - }; + ping(): 'pong' { + return 'pong'; }, + /** + * Ping a vat. + * + * @param vatId - The ID of the vat to ping. + * @returns A promise that resolves to the ping result. + */ + async pingVat(vatId: VatId): Promise { + return pingVat(vatId); + }, + + /** + * Get the bootstrap root kref of a system subcluster by name. + * + * @param name - The name of the system subcluster. + * @returns The bootstrap root kref. + * @throws If the system subcluster is not found. + */ + getSystemSubclusterRoot(name: string): KRef { + return getSystemSubclusterRoot(name); + }, + + /** + * Reset the kernel state. + * + * @returns A promise that resolves when the reset is complete. + */ + async reset(): Promise { + return reset(); + }, + + /** + * Launch a dynamic subcluster. + * + * @param args - Arguments to pass to launchSubcluster. + * @returns A promise for the launch result. + */ + launchSubcluster: async (...args: Parameters) => + launchSubcluster(...args), + /** * Terminate a subcluster. * @@ -171,7 +182,18 @@ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { getVatRoot(kref: string): SlotValue { return kslot(kref, 'vatRoot'); }, - }); - return kernelFacet; + /** + * Send a message to a vat. + * + * @param target - The vat to send the message to. + * @param method - The method name to call. + * @param args - Arguments to pass to the method. + * @returns The result from the subcluster. + */ + queueMessage(target: KRef, method: string, args: unknown[]): unknown { + return queueMessage(target, method, args); + }, + }); + return kernelFacet as KernelFacet; } diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 52dc339ae..10319a9f2 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -442,7 +442,7 @@ export type SubclusterLaunchResult = { /** The ID of the launched subcluster. */ subclusterId: string; /** The kref of the bootstrap vat's root object. */ - bootstrapRootKref: KRef; + rootKref: KRef; /** The CapData result of calling bootstrap() on the root object, if any. */ bootstrapResult: CapData | undefined; }; diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 427c825e4..b199f764a 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -103,7 +103,7 @@ describe('SubclusterManager', () => { ]); expect(result).toStrictEqual({ subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: { body: '{"result":"ok"}', slots: [] }, }); }); @@ -223,7 +223,7 @@ describe('SubclusterManager', () => { const result = await subclusterManager.launchSubcluster(config); expect(result).toStrictEqual({ subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult, }); }); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index f8498f7e1..212886412 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -92,9 +92,11 @@ export class SubclusterManager { Fail`invalid bootstrap vat name ${config.bootstrap}`; } const subclusterId = this.#kernelStore.addSubcluster(config); - const { bootstrapRootKref, bootstrapResult } = - await this.#launchVatsForSubcluster(subclusterId, config); - return { subclusterId, bootstrapRootKref, bootstrapResult }; + const { rootKref, bootstrapResult } = await this.#launchVatsForSubcluster( + subclusterId, + config, + ); + return { subclusterId, rootKref, bootstrapResult }; } /** @@ -216,7 +218,7 @@ export class SubclusterManager { subclusterId: string, config: ClusterConfig, ): Promise<{ - bootstrapRootKref: KRef; + rootKref: KRef; bootstrapResult: CapData | undefined; }> { const rootIds: Record = {}; @@ -238,22 +240,21 @@ export class SubclusterManager { } } } - const bootstrapRootKref = rootIds[config.bootstrap]; - if (!bootstrapRootKref) { + const rootKref = rootIds[config.bootstrap]; + if (!rootKref) { throw new Error( `Bootstrap vat "${config.bootstrap}" not found in rootIds`, ); } - const bootstrapResult = await this.#queueMessage( - bootstrapRootKref, - 'bootstrap', - [roots, services], - ); + const bootstrapResult = await this.#queueMessage(rootKref, 'bootstrap', [ + roots, + services, + ]); const unserialized = kunser(bootstrapResult); if (unserialized instanceof Error) { throw unserialized; } - return { bootstrapRootKref, bootstrapResult }; + return { rootKref, bootstrapResult }; } /** diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index d26ad18fb..47157c862 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -7,13 +7,11 @@ import { isConsoleForwardMessage, handleConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; -import type { - CapTPMessage, - KernelFacade, -} from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import type { KernelFacet } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import type { CapletManifest } from './controllers/index.ts'; @@ -112,7 +110,7 @@ async function main(): Promise { // Set up controller vat initialization (runs concurrently with stream drain) E(kernelP) .getSystemSubclusterRoot('omnium-controllers') - .then(({ kref }) => { + .then((kref) => { globals.setControllerVatKref(kref); logger.info('Controller vat initialized'); return undefined; @@ -142,7 +140,7 @@ async function main(): Promise { } type GlobalSetters = { - setKernel: (kernel: KernelFacade | Promise) => void; + setKernel: (kernel: KernelFacet | Promise) => void; // Not actually globally available setControllerVatKref: (kref: string) => void; }; @@ -165,9 +163,9 @@ function defineGlobals(): GlobalSetters { const callController = async ( method: string, args: unknown[] = [], - ): ReturnType => { + ): ReturnType => { if (!kernel) { - throw new Error('Kernel facade not initialized'); + throw new Error('Kernel not initialized'); } if (!controllerVatKref) { throw new Error('Controller vat not initialized'); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index a63017b7f..2fb728b44 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -307,7 +307,6 @@ export class CapletController extends Controller< throw new Error(`Caplet ${capletId} has no root object`); } - // Convert the stored kref string to a presence using the kernel facade return this.#getVatRoot(caplet.rootKref); } } diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 545ed5d14..32af9ede9 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,5 +1,5 @@ -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; import type { Promisified } from '@metamask/kernel-utils'; +import type { KernelFacet } from '@metamask/ocap-kernel'; import type { CapletControllerFacet, @@ -22,7 +22,7 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var kernel: KernelFacade | Promise; + var kernel: KernelFacet | Promise; // eslint-disable-next-line no-var var omnium: { diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 962964698..99895a81d 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -4,7 +4,10 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { PromiseKit } from '@endo/promise-kit'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Logger } from '@metamask/logger'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; +import type { + ClusterConfig, + SubclusterLaunchResult, +} from '@metamask/ocap-kernel'; import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; import type { Baggage } from './storage/baggage-adapter.ts'; @@ -26,7 +29,7 @@ type VatPowers = { * Kernel facet interface for system vat operations. */ type KernelFacet = { - launchSubcluster: (config: ClusterConfig) => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; terminateSubcluster: (subclusterId: string) => Promise; getVatRoot: (krefString: string) => Promise; }; diff --git a/yarn.lock b/yarn.lock index 5f1d6a6fa..93ebaa98d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3473,6 +3473,7 @@ __metadata: "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" From 3b5d23ef0db6f548c1eeffaa730909cfcd8c39d7 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:12:20 -0800 Subject: [PATCH 27/47] refactor(ocap-kernel): Replace getVatRoot with getPresence on Kernel Add Kernel.getPresence(kref, iface = 'Kernel Object') as a public method that wraps kslot(). Remove getVatRoot from KernelFacet and replace it with getPresence, which is now a delegated dependency rather than a standalone kslot call. Update controller-vat.ts to call E(kernelFacet).getPresence(kref, 'vatRoot') instead of E(kernelFacet).getVatRoot(kref). Co-Authored-By: Claude Opus 4.6 --- .../captp/captp.integration.test.ts | 7 ++++++- .../kernel-worker/captp/kernel-captp.test.ts | 7 ++++++- packages/ocap-kernel/src/Kernel.ts | 16 +++++++++++++++ packages/ocap-kernel/src/kernel-facet.test.ts | 17 +++++++++++----- packages/ocap-kernel/src/kernel-facet.ts | 20 +++++-------------- .../src/vats/controller-vat.ts | 4 ++-- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts index 8a232bff2..92b3ec17f 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts @@ -1,6 +1,6 @@ import { E } from '@endo/eventual-send'; import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; -import { makeKernelFacet } from '@metamask/ocap-kernel'; +import { makeKernelFacet, kslot } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { makeKernelCapTP } from './kernel-captp.ts'; @@ -21,6 +21,11 @@ describe('CapTP Integration', () => { beforeEach(() => { // Create mock kernel with method implementations mockKernel = { + getPresence: vi + .fn() + .mockImplementation(async (kref: string, iface: string) => + kslot(kref, iface), + ), getStatus: vi.fn().mockResolvedValue({ vats: [{ id: 'v1', name: 'test-vat' }], subclusters: ['sc1'], diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts index 08bc880dc..97e98d8ec 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -1,5 +1,5 @@ import type { Kernel } from '@metamask/ocap-kernel'; -import { makeKernelFacet } from '@metamask/ocap-kernel'; +import { makeKernelFacet, kslot } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { makeKernelCapTP } from './kernel-captp.ts'; @@ -7,6 +7,11 @@ import type { CapTPMessage } from '../../types.ts'; describe('makeKernelCapTP', () => { const mockKernel = { + getPresence: vi + .fn() + .mockImplementation(async (kref: string, iface: string) => + kslot(kref, iface), + ), getStatus: vi.fn(), getSubcluster: vi.fn(), getSubclusters: vi.fn(), diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 98b0b2ad4..67e272be7 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -8,6 +8,8 @@ import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; import type { KernelService } from './KernelServiceManager.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts'; import { RemoteManager } from './remotes/kernel/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; @@ -372,6 +374,7 @@ export class Kernel { } const kernelFacet = makeKernelFacet({ + getPresence: this.getPresence.bind(this), getStatus: this.getStatus.bind(this), getSubcluster: this.getSubcluster.bind(this), getSubclusters: this.getSubclusters.bind(this), @@ -531,6 +534,19 @@ export class Kernel { return kref; } + /** + * Convert a kref string to a slot value (presence). + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @param iface - The interface name for the slot value. + * @returns The slot value that will become a presence when marshalled. + */ + getPresence(kref: string, iface: string = 'Kernel Object'): SlotValue { + return kslot(kref, iface); + } + /** * Checks if a vat belongs to a specific subcluster. * diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 51c5ae243..22f648b0b 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { KernelFacetDependencies } from './kernel-facet.ts'; import { makeKernelFacet } from './kernel-facet.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import { krefOf } from './liveslots/kernel-marshal.ts'; +import { krefOf, kslot } from './liveslots/kernel-marshal.ts'; import type { ClusterConfig, KernelStatus, Subcluster } from './types.ts'; describe('makeKernelFacet', () => { @@ -11,6 +11,12 @@ describe('makeKernelFacet', () => { beforeEach(() => { deps = { + getPresence: vi + .fn() + // eslint-disable-next-line @typescript-eslint/promise-function-async + .mockImplementation((kref: string, iface: string) => + kslot(kref, iface), + ), getStatus: vi.fn().mockResolvedValue({ vats: [], subclusters: [], @@ -188,14 +194,15 @@ describe('makeKernelFacet', () => { }); }); - describe('getVatRoot', () => { - it('returns a slot value for the given kref', () => { + describe('getPresence', () => { + it('delegates to the getPresence dependency', () => { const facet = makeKernelFacet(deps) as { - getVatRoot: (kref: string) => SlotValue; + getPresence: (kref: string, iface?: string) => SlotValue; }; - const result = facet.getVatRoot('ko42'); + const result = facet.getPresence('ko42', 'vatRoot'); + expect(deps.getPresence).toHaveBeenCalledWith('ko42', 'vatRoot'); expect(krefOf(result)).toBe('ko42'); }); }); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 88bd90d0c..d89b50531 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -1,7 +1,6 @@ import { makeDefaultExo } from '@metamask/kernel-utils'; import type { Kernel } from './Kernel.ts'; -import { kslot } from './liveslots/kernel-marshal.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; import type { PingVatResult } from './rpc/index.ts'; import type { Subcluster, KernelStatus, KRef, VatId } from './types.ts'; @@ -11,6 +10,7 @@ import type { Subcluster, KernelStatus, KRef, VatId } from './types.ts'; */ export type KernelFacetDependencies = Pick< Kernel, + | 'getPresence' | 'getStatus' | 'getSubcluster' | 'getSubclusters' @@ -36,16 +36,6 @@ export type KernelFacet = KernelFacetDependencies & { * @returns The string 'pong'. */ ping: () => 'pong'; - - /** - * Convert a kref string to a slot value (presence). - * - * Use this to restore a presence from a stored kref string after restart. - * - * @param kref - The kref string to convert. - * @returns The slot value that will become a presence when marshalled. - */ - getVatRoot: (kref: string) => SlotValue; }; /** @@ -63,6 +53,7 @@ export type KernelFacet = KernelFacetDependencies & { */ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { const { + getPresence, getStatus, getSubcluster, getSubclusters, @@ -174,13 +165,12 @@ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { /** * Convert a kref string to a slot value (presence). * - * Use this to restore a presence from a stored kref string after restart. - * * @param kref - The kref string to convert. + * @param iface - The interface name for the slot value. * @returns The slot value that will become a presence when marshalled. */ - getVatRoot(kref: string): SlotValue { - return kslot(kref, 'vatRoot'); + getPresence(kref: string, iface: string = 'Kernel Object'): SlotValue { + return getPresence(kref, iface); }, /** diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 99895a81d..2d9efc4eb 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -31,7 +31,7 @@ type VatPowers = { type KernelFacet = { launchSubcluster: (config: ClusterConfig) => Promise; terminateSubcluster: (subclusterId: string) => Promise; - getVatRoot: (krefString: string) => Promise; + getPresence: (kref: string, iface?: string) => Promise; }; /** @@ -77,7 +77,7 @@ async function initializeCapletController(options: { terminateSubcluster: async (subclusterId: string): Promise => E(kernelFacet).terminateSubcluster(subclusterId), getVatRoot: async (krefString: string): Promise => - E(kernelFacet).getVatRoot(krefString), + E(kernelFacet).getPresence(krefString, 'vatRoot'), }, ); resolve(capletFacet); From f660f3164c1ec78648702f09337adc4ca5110db1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:17:29 -0800 Subject: [PATCH 28/47] refactor(ocap-kernel): Simplify makeKernelFacet to spread deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace individual method declarations with a spread of the deps object. Since every method except ping() is a direct delegate, the facet is now just `makeDefaultExo('kernelFacet', { ...deps, ping })`. Simplify tests accordingly — use plain functions instead of vi.fn() mocks (which get frozen by harden()). Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/kernel-facet.test.ts | 338 ++++-------------- packages/ocap-kernel/src/kernel-facet.ts | 154 +------- 2 files changed, 68 insertions(+), 424 deletions(-) diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 22f648b0b..fa31d82d9 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -1,292 +1,74 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import type { KernelFacetDependencies } from './kernel-facet.ts'; import { makeKernelFacet } from './kernel-facet.ts'; -import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import { krefOf, kslot } from './liveslots/kernel-marshal.ts'; -import type { ClusterConfig, KernelStatus, Subcluster } from './types.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; + +const makeDeps = (): KernelFacetDependencies => ({ + getPresence: async (kref: string, iface: string = 'Kernel Object') => + kslot(kref, iface), + getStatus: async () => Promise.resolve({ vats: [], subclusters: [] }), + getSubcluster: () => undefined, + getSubclusters: () => [], + getSystemSubclusterRoot: () => 'ko99', + launchSubcluster: async () => + Promise.resolve({ + subclusterId: 's1', + rootKref: 'ko1', + bootstrapResult: undefined, + }), + pingVat: async () => Promise.resolve('pong'), + queueMessage: async () => Promise.resolve({ body: '#null', slots: [] }), + reloadSubcluster: async () => + Promise.resolve({ + id: 's1', + config: { bootstrap: 'b', vats: {} }, + vats: [], + }), + reset: async () => Promise.resolve(), + terminateSubcluster: async () => Promise.resolve(), +}); describe('makeKernelFacet', () => { - let deps: KernelFacetDependencies; - - beforeEach(() => { - deps = { - getPresence: vi - .fn() - // eslint-disable-next-line @typescript-eslint/promise-function-async - .mockImplementation((kref: string, iface: string) => - kslot(kref, iface), - ), - getStatus: vi.fn().mockResolvedValue({ - vats: [], - subclusters: [], - remoteComms: { isInitialized: false }, - }), - getSubcluster: vi.fn().mockReturnValue({ - id: 's1', - config: { bootstrap: 'test', vats: {} }, - vats: {}, - }), - getSubclusters: vi - .fn() - .mockReturnValue([ - { id: 's1', config: { bootstrap: 'test', vats: {} }, vats: {} }, - ]), - getSystemSubclusterRoot: vi.fn().mockReturnValue('ko99'), - launchSubcluster: vi.fn().mockResolvedValue({ - subclusterId: 's1', - rootKref: 'ko1', - }), - pingVat: vi.fn().mockResolvedValue({ alive: true }), - queueMessage: vi.fn().mockResolvedValue({ - body: '#{"result":"ok"}', - slots: [], - }), - reloadSubcluster: vi.fn().mockResolvedValue({ - id: 's2', - config: { bootstrap: 'test', vats: {} }, - vats: {}, - }), - reset: vi.fn().mockResolvedValue(undefined), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), - }; - }); - - it('creates a kernel facet object', () => { - const facet = makeKernelFacet(deps); - expect(facet).toBeDefined(); - expect(typeof facet).toBe('object'); - }); - - describe('launchSubcluster', () => { - it('delegates to the launchSubcluster dependency', async () => { - const facet = makeKernelFacet(deps) as { - launchSubcluster: (config: ClusterConfig) => Promise; - }; - const config: ClusterConfig = { - bootstrap: 'myVat', - vats: { myVat: { sourceSpec: 'test.js' } }, - }; - - const result = await facet.launchSubcluster(config); - - expect(deps.launchSubcluster).toHaveBeenCalledWith(config); - expect(result).toStrictEqual({ - subclusterId: 's1', - rootKref: 'ko1', - }); - }); + it('creates an exo with all dependency methods and ping', () => { + const facet = makeKernelFacet(makeDeps()); + + expect(typeof facet.getPresence).toBe('function'); + expect(typeof facet.getStatus).toBe('function'); + expect(typeof facet.getSubcluster).toBe('function'); + expect(typeof facet.getSubclusters).toBe('function'); + expect(typeof facet.getSystemSubclusterRoot).toBe('function'); + expect(typeof facet.launchSubcluster).toBe('function'); + expect(typeof facet.ping).toBe('function'); + expect(typeof facet.pingVat).toBe('function'); + expect(typeof facet.queueMessage).toBe('function'); + expect(typeof facet.reloadSubcluster).toBe('function'); + expect(typeof facet.reset).toBe('function'); + expect(typeof facet.terminateSubcluster).toBe('function'); }); - describe('terminateSubcluster', () => { - it('calls the terminateSubcluster dependency', async () => { - const facet = makeKernelFacet(deps) as { - terminateSubcluster: (id: string) => Promise; - }; - - await facet.terminateSubcluster('s1'); - - expect(deps.terminateSubcluster).toHaveBeenCalledWith('s1'); - }); + it('ping returns "pong"', () => { + const facet = makeKernelFacet(makeDeps()); + expect(facet.ping()).toBe('pong'); }); - describe('reloadSubcluster', () => { - it('calls the reloadSubcluster dependency', async () => { - const facet = makeKernelFacet(deps) as { - reloadSubcluster: (id: string) => Promise; - }; - - await facet.reloadSubcluster('s1'); - - expect(deps.reloadSubcluster).toHaveBeenCalledWith('s1'); - }); - - it('returns the reloaded subcluster', async () => { - const facet = makeKernelFacet(deps) as { - reloadSubcluster: (id: string) => Promise; - }; - - const result = await facet.reloadSubcluster('s1'); + it('delegates dependency methods to the provided functions', async () => { + const facet = makeKernelFacet(makeDeps()); - expect(result.id).toBe('s2'); + expect(facet.getSystemSubclusterRoot('test')).toBe('ko99'); + expect(await facet.getStatus()).toStrictEqual({ + vats: [], + subclusters: [], }); - }); - - describe('getSubcluster', () => { - it('calls the getSubcluster dependency', () => { - const facet = makeKernelFacet(deps) as { - getSubcluster: (id: string) => Subcluster | undefined; - }; - - facet.getSubcluster('s1'); - - expect(deps.getSubcluster).toHaveBeenCalledWith('s1'); - }); - - it('returns the subcluster', () => { - const facet = makeKernelFacet(deps) as { - getSubcluster: (id: string) => Subcluster | undefined; - }; - - const result = facet.getSubcluster('s1'); - - expect(result?.id).toBe('s1'); - }); - - it('returns undefined for unknown subcluster', () => { - vi.spyOn(deps, 'getSubcluster').mockImplementation(() => undefined); - const facet = makeKernelFacet(deps) as { - getSubcluster: (id: string) => Subcluster | undefined; - }; - - const result = facet.getSubcluster('unknown'); - - expect(result).toBeUndefined(); - }); - }); - - describe('getSubclusters', () => { - it('calls the getSubclusters dependency', () => { - const facet = makeKernelFacet(deps) as { - getSubclusters: () => Subcluster[]; - }; - - facet.getSubclusters(); - - expect(deps.getSubclusters).toHaveBeenCalled(); - }); - - it('returns all subclusters', () => { - const facet = makeKernelFacet(deps) as { - getSubclusters: () => Subcluster[]; - }; - - const result = facet.getSubclusters(); - - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe('s1'); - }); - }); - - describe('getStatus', () => { - it('calls the getStatus dependency', async () => { - const facet = makeKernelFacet(deps) as { - getStatus: () => Promise; - }; - - await facet.getStatus(); - - expect(deps.getStatus).toHaveBeenCalled(); - }); - - it('returns kernel status', async () => { - const facet = makeKernelFacet(deps) as { - getStatus: () => Promise; - }; - - const result = await facet.getStatus(); - - expect(result).toStrictEqual({ - vats: [], - subclusters: [], - remoteComms: { isInitialized: false }, - }); - }); - }); - - describe('getPresence', () => { - it('delegates to the getPresence dependency', () => { - const facet = makeKernelFacet(deps) as { - getPresence: (kref: string, iface?: string) => SlotValue; - }; - - const result = facet.getPresence('ko42', 'vatRoot'); - - expect(deps.getPresence).toHaveBeenCalledWith('ko42', 'vatRoot'); - expect(krefOf(result)).toBe('ko42'); - }); - }); - - describe('ping', () => { - it('returns "pong"', () => { - const facet = makeKernelFacet(deps) as { - ping: () => 'pong'; - }; - - expect(facet.ping()).toBe('pong'); - }); - }); - - describe('pingVat', () => { - it('delegates to the pingVat dependency', async () => { - const facet = makeKernelFacet(deps) as { - pingVat: (vatId: string) => Promise; - }; - - const result = await facet.pingVat('v1'); - - expect(result).toStrictEqual({ alive: true }); - expect(deps.pingVat).toHaveBeenCalledWith('v1'); - }); - }); - - describe('getSystemSubclusterRoot', () => { - it('returns the kref for a known system subcluster', () => { - const facet = makeKernelFacet(deps) as { - getSystemSubclusterRoot: (name: string) => string; - }; - - const result = facet.getSystemSubclusterRoot('my-system'); - - expect(result).toBe('ko99'); - expect(deps.getSystemSubclusterRoot).toHaveBeenCalledWith('my-system'); - }); - - it('propagates errors from the dependency', () => { - vi.mocked(deps.getSystemSubclusterRoot).mockImplementation(() => { - throw new Error('System subcluster "unknown" not found'); - }); - const facet = makeKernelFacet(deps) as { - getSystemSubclusterRoot: (name: string) => string; - }; - - expect(() => facet.getSystemSubclusterRoot('unknown')).toThrow( - 'System subcluster "unknown" not found', - ); - }); - }); - - describe('reset', () => { - it('delegates to the reset dependency', async () => { - const facet = makeKernelFacet(deps) as { - reset: () => Promise; - }; - - await facet.reset(); - - expect(deps.reset).toHaveBeenCalled(); - }); - }); - - describe('queueMessage', () => { - it('delegates to the queueMessage dependency', async () => { - const facet = makeKernelFacet(deps) as { - queueMessage: ( - target: string, - method: string, - args: unknown[], - ) => Promise; - }; - - const result = await facet.queueMessage('ko1', 'doThing', ['arg1']); - - expect(result).toStrictEqual({ - body: '#{"result":"ok"}', - slots: [], - }); - expect(deps.queueMessage).toHaveBeenCalledWith('ko1', 'doThing', [ - 'arg1', - ]); + expect( + await facet.launchSubcluster({ + bootstrap: 'b', + vats: { b: { bundleSpec: 'x' } }, + }), + ).toStrictEqual({ + subclusterId: 's1', + rootKref: 'ko1', + bootstrapResult: undefined, }); }); }); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index d89b50531..f0bc8f7e6 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -1,9 +1,6 @@ import { makeDefaultExo } from '@metamask/kernel-utils'; import type { Kernel } from './Kernel.ts'; -import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import type { PingVatResult } from './rpc/index.ts'; -import type { Subcluster, KernelStatus, KRef, VatId } from './types.ts'; /** * Dependencies required to create a kernel facet. @@ -39,151 +36,16 @@ export type KernelFacet = KernelFacetDependencies & { }; /** - * Creates a kernel facet object that provides privileged kernel operations. + * Creates a kernel facet exo that provides privileged kernel operations. * - * The kernel facet is provided as a vatpower to the bootstrap vat of a - * system vat. It enables the bootstrap vat to: - * - Launch dynamic subclusters (and receive E()-callable presences) - * - Terminate subclusters - * - Reload subclusters - * - Query kernel status + * All methods except ping() are delegated directly from the kernel. * - * @param deps - Dependencies for creating the kernel facet. - * @returns The kernel facet object. + * @param deps - Bound kernel methods to expose on the facet. + * @returns The kernel facet exo. */ export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { - const { - getPresence, - getStatus, - getSubcluster, - getSubclusters, - getSystemSubclusterRoot, - launchSubcluster, - pingVat, - queueMessage, - reloadSubcluster, - reset, - terminateSubcluster, - } = deps; - - const kernelFacet = makeDefaultExo('kernelFacet', { - /** - * Ping the kernel. - * - * @returns The string 'pong'. - */ - ping(): 'pong' { - return 'pong'; - }, - - /** - * Ping a vat. - * - * @param vatId - The ID of the vat to ping. - * @returns A promise that resolves to the ping result. - */ - async pingVat(vatId: VatId): Promise { - return pingVat(vatId); - }, - - /** - * Get the bootstrap root kref of a system subcluster by name. - * - * @param name - The name of the system subcluster. - * @returns The bootstrap root kref. - * @throws If the system subcluster is not found. - */ - getSystemSubclusterRoot(name: string): KRef { - return getSystemSubclusterRoot(name); - }, - - /** - * Reset the kernel state. - * - * @returns A promise that resolves when the reset is complete. - */ - async reset(): Promise { - return reset(); - }, - - /** - * Launch a dynamic subcluster. - * - * @param args - Arguments to pass to launchSubcluster. - * @returns A promise for the launch result. - */ - launchSubcluster: async (...args: Parameters) => - launchSubcluster(...args), - - /** - * Terminate a subcluster. - * - * @param subclusterId - ID of the subcluster to terminate. - */ - async terminateSubcluster(subclusterId: string): Promise { - await terminateSubcluster(subclusterId); - }, - - /** - * Reload a subcluster by terminating and relaunching all its vats. - * - * @param subclusterId - ID of the subcluster to reload. - * @returns The reloaded subcluster information. - */ - async reloadSubcluster(subclusterId: string): Promise { - return reloadSubcluster(subclusterId); - }, - - /** - * Get information about a specific subcluster. - * - * @param subclusterId - ID of the subcluster to query. - * @returns The subcluster information or undefined if not found. - */ - getSubcluster(subclusterId: string): Subcluster | undefined { - return getSubcluster(subclusterId); - }, - - /** - * Get information about all subclusters. - * - * @returns Array of all subcluster information records. - */ - getSubclusters(): Subcluster[] { - return getSubclusters(); - }, - - /** - * Get the current kernel status. - * - * @returns A promise for the kernel status. - */ - async getStatus(): Promise { - return getStatus(); - }, - - /** - * Convert a kref string to a slot value (presence). - * - * @param kref - The kref string to convert. - * @param iface - The interface name for the slot value. - * @returns The slot value that will become a presence when marshalled. - */ - getPresence(kref: string, iface: string = 'Kernel Object'): SlotValue { - return getPresence(kref, iface); - }, - - /** - * Send a message to a vat. - * - * @param target - The vat to send the message to. - * @param method - The method name to call. - * @param args - Arguments to pass to the method. - * @returns The result from the subcluster. - */ - queueMessage(target: KRef, method: string, args: unknown[]): unknown { - return queueMessage(target, method, args); - }, - }); - return kernelFacet as KernelFacet; + return makeDefaultExo('kernelFacet', { + ...deps, + ping: () => 'pong' as const, + }) as KernelFacet; } From d37faa0a7227ba03e75d6688ab300ac02b3019de Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:21:08 -0800 Subject: [PATCH 29/47] test(nodejs): Fix broken e2e test --- .../nodejs/test/e2e/system-subcluster.test.ts | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/nodejs/test/e2e/system-subcluster.test.ts b/packages/nodejs/test/e2e/system-subcluster.test.ts index c0138c5e9..2fc2c90f5 100644 --- a/packages/nodejs/test/e2e/system-subcluster.test.ts +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -73,7 +73,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(root).toMatch(/^ko\d+$/u); }); - it('returns undefined for unknown system subcluster name', async () => { + it('throws for unknown system subcluster name', async () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); @@ -81,8 +81,9 @@ describe('System Subcluster', { timeout: 30_000 }, () => { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); - const root = kernel.getSystemSubclusterRoot('unknown-vat'); - expect(root).toBeUndefined(); + expect(() => kernel!.getSystemSubclusterRoot('unknown-cluster')).toThrow( + 'System subcluster "unknown-cluster" not found', + ); }); }); @@ -98,7 +99,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); - const result = await kernel.queueMessage(root!, 'hasKernelFacet', []); + const result = await kernel.queueMessage(root, 'hasKernelFacet', []); await delay(); expect(kunser(result)).toBe(true); @@ -115,7 +116,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); - const result = await kernel.queueMessage(root!, 'getKernelStatus', []); + const result = await kernel.queueMessage(root, 'getKernelStatus', []); await delay(); const status = kunser(result) as { @@ -140,7 +141,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { const root = kernel.getSystemSubclusterRoot('test-system'); expect(root).toBeDefined(); - const result = await kernel.queueMessage(root!, 'getSubclusters', []); + const result = await kernel.queueMessage(root, 'getSubclusters', []); await delay(); const subclusters = kunser(result) as unknown[]; @@ -164,7 +165,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Get initial subcluster count const initialResult = await kernel.queueMessage( - root!, + root, 'getSubclusters', [], ); @@ -183,15 +184,11 @@ describe('System Subcluster', { timeout: 30_000 }, () => { }, }; - await kernel.queueMessage(root!, 'launchSubcluster', [config]); + await kernel.queueMessage(root, 'launchSubcluster', [config]); await delay(); // Verify subcluster was created - const afterResult = await kernel.queueMessage( - root!, - 'getSubclusters', - [], - ); + const afterResult = await kernel.queueMessage(root, 'getSubclusters', []); await delay(); const afterSubclusters = kunser(afterResult) as unknown[]; @@ -220,18 +217,16 @@ describe('System Subcluster', { timeout: 30_000 }, () => { }, }; - const launchResult = await kernel.queueMessage( - root!, - 'launchSubcluster', - [config], - ); + const launchResult = await kernel.queueMessage(root, 'launchSubcluster', [ + config, + ]); await delay(); const launchData = kunser(launchResult) as { subclusterId: string }; const { subclusterId } = launchData; // Get count before termination const beforeResult = await kernel.queueMessage( - root!, + root, 'getSubclusters', [], ); @@ -240,15 +235,11 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(beforeSubclusters).toHaveLength(2); // Terminate the subcluster - await kernel.queueMessage(root!, 'terminateSubcluster', [subclusterId]); + await kernel.queueMessage(root, 'terminateSubcluster', [subclusterId]); await delay(); // Verify subcluster was terminated - const afterResult = await kernel.queueMessage( - root!, - 'getSubclusters', - [], - ); + const afterResult = await kernel.queueMessage(root, 'getSubclusters', []); await delay(); const afterSubclusters = kunser(afterResult) as unknown[]; @@ -296,7 +287,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(newRoot).toBeDefined(); expect(newRoot).toBe(initialRoot); - const result = await kernel.queueMessage(newRoot!, 'hasKernelFacet', []); + const result = await kernel.queueMessage(newRoot, 'hasKernelFacet', []); await delay(); expect(kunser(result)).toBe(true); }); @@ -315,11 +306,11 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Store data in baggage during first incarnation const testKey = 'persistent-data'; const testValue = 'hello from first incarnation'; - await kernel.queueMessage(root!, 'storeToBaggage', [testKey, testValue]); + await kernel.queueMessage(root, 'storeToBaggage', [testKey, testValue]); await delay(); // Verify data was stored - const storedResult = await kernel.queueMessage(root!, 'getFromBaggage', [ + const storedResult = await kernel.queueMessage(root, 'getFromBaggage', [ testKey, ]); await delay(); @@ -343,7 +334,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Verify baggage data persisted across restart const persistedResult = await kernel.queueMessage( - newRoot!, + newRoot, 'getFromBaggage', [testKey], ); @@ -351,16 +342,14 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(kunser(persistedResult)).toBe(testValue); // Verify key exists check works - const hasKeyResult = await kernel.queueMessage( - newRoot!, - 'hasBaggageKey', - [testKey], - ); + const hasKeyResult = await kernel.queueMessage(newRoot, 'hasBaggageKey', [ + testKey, + ]); await delay(); expect(kunser(hasKeyResult)).toBe(true); // Verify non-existent key returns false - const noKeyResult = await kernel.queueMessage(newRoot!, 'hasBaggageKey', [ + const noKeyResult = await kernel.queueMessage(newRoot, 'hasBaggageKey', [ 'non-existent-key', ]); await delay(); @@ -388,11 +377,11 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(root1).not.toBe(root2); // Both should have kernelFacet - const result1 = await kernel.queueMessage(root1!, 'hasKernelFacet', []); + const result1 = await kernel.queueMessage(root1, 'hasKernelFacet', []); await delay(); expect(kunser(result1)).toBe(true); - const result2 = await kernel.queueMessage(root2!, 'hasKernelFacet', []); + const result2 = await kernel.queueMessage(root2, 'hasKernelFacet', []); await delay(); expect(kunser(result2)).toBe(true); From 5b5b90f9e9b2db27db3d2429b1c2c75329356121 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:39:10 -0800 Subject: [PATCH 30/47] refactor(ocap-kernel): Change Subcluster.vats from VatId[] to Record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the array-based vat storage with a name-keyed record, making the vat name→ID relationship explicit and eliminating the fragile index-based bootstrap vat lookup in Kernel.ts. Co-Authored-By: Claude Opus 4.6 --- packages/kernel-test/src/subclusters.test.ts | 2 +- packages/ocap-kernel/src/Kernel.test.ts | 6 +- packages/ocap-kernel/src/Kernel.ts | 4 +- .../ocap-kernel/src/store/methods/gc.test.ts | 2 +- .../src/store/methods/subclusters.test.ts | 74 ++++++++++--------- .../src/store/methods/subclusters.ts | 29 +++++--- packages/ocap-kernel/src/types.ts | 2 +- .../src/vats/SubclusterManager.test.ts | 8 +- .../ocap-kernel/src/vats/SubclusterManager.ts | 10 ++- .../ocap-kernel/src/vats/VatHandle.test.ts | 2 +- .../ocap-kernel/src/vats/VatManager.test.ts | 8 +- packages/ocap-kernel/src/vats/VatManager.ts | 9 ++- 12 files changed, 92 insertions(+), 64 deletions(-) diff --git a/packages/kernel-test/src/subclusters.test.ts b/packages/kernel-test/src/subclusters.test.ts index 6193cf21d..8cc0be30c 100644 --- a/packages/kernel-test/src/subclusters.test.ts +++ b/packages/kernel-test/src/subclusters.test.ts @@ -193,6 +193,6 @@ describe('Subcluster functionality', () => { const reloadedSubcluster = await kernel.reloadSubcluster('s1'); console.log('reloadedSubcluster', reloadedSubcluster); expect(reloadedSubcluster).toBeDefined(); - expect(reloadedSubcluster.vats).toHaveLength(2); + expect(Object.keys(reloadedSubcluster.vats)).toHaveLength(2); }); }); diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 24ccdbd68..a7469e80b 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -932,7 +932,7 @@ describe('Kernel', () => { ).toThrow('System subcluster "testSystemSubcluster" not found'); }); - it('throws if persisted system subcluster has no vats', async () => { + it('throws if persisted system subcluster has no bootstrap vat', async () => { const db = makeMapKernelDatabase(); const systemSubclusterConfig = { name: 'testSystemSubcluster', @@ -955,7 +955,7 @@ describe('Kernel', () => { // Corrupt database: remove vats from the subcluster const subclustersJson = db.kernelKVStore.get('subclusters'); const subclusters = JSON.parse(subclustersJson ?? '[]'); - subclusters[0].vats = []; + subclusters[0].vats = {}; db.kernelKVStore.set('subclusters', JSON.stringify(subclusters)); // Restart kernel - should throw @@ -963,7 +963,7 @@ describe('Kernel', () => { Kernel.make(mockPlatformServices, db, { systemSubclusters: [systemSubclusterConfig], }), - ).rejects.toThrow('has no vats - database may be corrupted'); + ).rejects.toThrow('has no bootstrap vat - database may be corrupted'); }); it('throws if persisted system subcluster has no root object', async () => { diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 67e272be7..333a958bd 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -303,10 +303,10 @@ export class Kernel { continue; } - const bootstrapVatId = subcluster.vats[0]; + const bootstrapVatId = subcluster.vats[subcluster.config.bootstrap]; if (!bootstrapVatId) { throw new Error( - `System subcluster "${name}" has no vats - database may be corrupted`, + `System subcluster "${name}" has no bootstrap vat - database may be corrupted`, ); } diff --git a/packages/ocap-kernel/src/store/methods/gc.test.ts b/packages/ocap-kernel/src/store/methods/gc.test.ts index b8a12f196..e18a75d33 100644 --- a/packages/ocap-kernel/src/store/methods/gc.test.ts +++ b/packages/ocap-kernel/src/store/methods/gc.test.ts @@ -195,7 +195,7 @@ describe('GC methods', () => { bootstrap: 'v1', vats: { v1: { bundleName: 'test' } }, }); - kernelStore.addSubclusterVat(subclusterId, 'v1'); + kernelStore.addSubclusterVat(subclusterId, 'v1', 'v1'); const ko1 = kernelStore.initKernelObject('v1'); diff --git a/packages/ocap-kernel/src/store/methods/subclusters.test.ts b/packages/ocap-kernel/src/store/methods/subclusters.test.ts index 0acca95a6..eda07e6bb 100644 --- a/packages/ocap-kernel/src/store/methods/subclusters.test.ts +++ b/packages/ocap-kernel/src/store/methods/subclusters.test.ts @@ -112,7 +112,7 @@ describe('getSubclusterMethods', () => { expect(subclusters[0]).toStrictEqual({ id: 's2', config: mockClusterConfig1, - vats: [], + vats: {}, }); }); @@ -132,7 +132,7 @@ describe('getSubclusterMethods', () => { expect(subclusters[1]).toStrictEqual({ id: 's3', config: mockClusterConfig2, - vats: [], + vats: {}, }); }); }); @@ -181,14 +181,14 @@ describe('getSubclusterMethods', () => { }); it('should add a vat to an existing subcluster and update map', () => { - subclusterMethods.addSubclusterVat(scId, vatId1); + subclusterMethods.addSubclusterVat(scId, 'bob', vatId1); const subclustersRaw = mockSubclustersStorage.get(); const subclusters = subclustersRaw ? (JSON.parse(subclustersRaw) as Subcluster[]) : []; const targetSubcluster = subclusters.find((sc) => sc.id === scId); - expect(targetSubcluster?.vats).toContain(vatId1); + expect(Object.values(targetSubcluster?.vats ?? {})).toContain(vatId1); const mapRaw = mockVatToSubclusterMapStorage.get(); const map = mapRaw ? JSON.parse(mapRaw) : {}; @@ -196,22 +196,24 @@ describe('getSubclusterMethods', () => { }); it('should not add a duplicate vat to the same subcluster', () => { - subclusterMethods.addSubclusterVat(scId, vatId1); - subclusterMethods.addSubclusterVat(scId, vatId1); + subclusterMethods.addSubclusterVat(scId, 'bob', vatId1); + subclusterMethods.addSubclusterVat(scId, 'bob', vatId1); const subclustersRaw = mockSubclustersStorage.get(); const subclusters = subclustersRaw ? (JSON.parse(subclustersRaw) as Subcluster[]) : []; const targetSubcluster = subclusters.find((sc) => sc.id === scId); - expect(targetSubcluster?.vats).toStrictEqual([vatId1]); + expect(targetSubcluster?.vats).toStrictEqual({ bob: vatId1 }); }); it('should throw an error when trying to add a vat to a different subcluster', () => { const scId2 = subclusterMethods.addSubcluster(mockClusterConfig2); - subclusterMethods.addSubclusterVat(scId, vatId1); + subclusterMethods.addSubclusterVat(scId, 'bob', vatId1); - expect(() => subclusterMethods.addSubclusterVat(scId2, vatId1)).toThrow( + expect(() => + subclusterMethods.addSubclusterVat(scId2, 'alice', vatId1), + ).toThrow( `Cannot add vat ${vatId1} to subcluster ${scId2} as it already belongs to subcluster ${scId}.`, ); @@ -223,8 +225,8 @@ describe('getSubclusterMethods', () => { const originalSc = subclusters.find((sc) => sc.id === scId); const newSc = subclusters.find((sc) => sc.id === scId2); - expect(originalSc?.vats).toContain(vatId1); - expect(newSc?.vats).not.toContain(vatId1); + expect(Object.values(originalSc?.vats ?? {})).toContain(vatId1); + expect(Object.values(newSc?.vats ?? {})).not.toContain(vatId1); // Verify the map hasn't changed const mapRaw = mockVatToSubclusterMapStorage.get(); @@ -235,7 +237,7 @@ describe('getSubclusterMethods', () => { it('should throw SubclusterNotFoundError if subcluster does not exist', () => { const nonExistentId = 'sNonExistent' as SubclusterId; expect(() => - subclusterMethods.addSubclusterVat(nonExistentId, vatId1), + subclusterMethods.addSubclusterVat(nonExistentId, 'bob', vatId1), ).toThrow(SubclusterNotFoundError); }); }); @@ -247,8 +249,8 @@ describe('getSubclusterMethods', () => { beforeEach(() => { scId = subclusterMethods.addSubcluster(mockClusterConfig1); - subclusterMethods.addSubclusterVat(scId, vatId1); - subclusterMethods.addSubclusterVat(scId, vatId2); + subclusterMethods.addSubclusterVat(scId, 'vat1', vatId1); + subclusterMethods.addSubclusterVat(scId, 'vat2', vatId2); }); it('should return all vats for a given subcluster', () => { @@ -271,7 +273,7 @@ describe('getSubclusterMethods', () => { ); }); - it('should return a copy of the vats array, not a reference', () => { + it('should return a copy of the vats, not a reference', () => { const vatsArray1 = subclusterMethods.getSubclusterVats(scId); expect(vatsArray1).toStrictEqual([vatId1, vatId2]); vatsArray1.push('v3-mutated' as VatId); @@ -289,16 +291,16 @@ describe('getSubclusterMethods', () => { beforeEach(() => { scId1 = subclusterMethods.addSubcluster(mockClusterConfig1); - subclusterMethods.addSubclusterVat(scId1, vatId1); - subclusterMethods.addSubclusterVat(scId1, vatId2); + subclusterMethods.addSubclusterVat(scId1, 'vat1', vatId1); + subclusterMethods.addSubclusterVat(scId1, 'vat2', vatId2); }); it('should delete a vat from a subcluster and update map', () => { subclusterMethods.deleteSubclusterVat(scId1, vatId2); const sc1 = subclusterMethods.getSubcluster(scId1) as Subcluster; - expect(sc1.vats).not.toContain(vatId2); - expect(sc1.vats).toContain(vatId1); + expect(Object.values(sc1.vats)).not.toContain(vatId2); + expect(Object.values(sc1.vats)).toContain(vatId1); const mapRaw = mockVatToSubclusterMapStorage.get(); const map = mapRaw ? JSON.parse(mapRaw) : {}; @@ -325,7 +327,7 @@ describe('getSubclusterMethods', () => { it('should do nothing to subclusters list if vat is not in the specified subcluster', () => { const nonExistentVat = 'vNonExistent' as VatId; const sc1 = subclusterMethods.getSubcluster(scId1) as Subcluster; - const sc1InitialVats = sc1 ? [...sc1.vats] : []; + const sc1InitialVats = sc1 ? { ...sc1.vats } : {}; subclusterMethods.deleteSubclusterVat(scId1, nonExistentVat); @@ -353,8 +355,8 @@ describe('getSubclusterMethods', () => { beforeEach(() => { scId1 = subclusterMethods.addSubcluster(mockClusterConfig1); - subclusterMethods.addSubclusterVat(scId1, vatId1); - subclusterMethods.addSubclusterVat(scId1, vatId2); + subclusterMethods.addSubclusterVat(scId1, 'vat1', vatId1); + subclusterMethods.addSubclusterVat(scId1, 'vat2', vatId2); }); it('should delete a subcluster and remove its vats from the map', () => { @@ -390,14 +392,14 @@ describe('getSubclusterMethods', () => { it('should correctly update map if a vat was in multiple subclusters conceptually', () => { const scId2 = subclusterMethods.addSubcluster(mockClusterConfig2); const vatX = 'vX' as VatId; - subclusterMethods.addSubclusterVat(scId1, vatX); + subclusterMethods.addSubclusterVat(scId1, 'vatX', vatX); const subclustersRaw = mockSubclustersStorage.get(); const subclusters = subclustersRaw ? (JSON.parse(subclustersRaw) as Subcluster[]) : []; - const sc2obj = subclusters.find((sc) => sc.id === scId2); - sc2obj?.vats.push(vatX); + const sc2obj = subclusters.find((sc) => sc.id === scId2) as Subcluster; + sc2obj.vats.vatX = vatX; mockSubclustersStorage.set(JSON.stringify(subclusters)); subclusterMethods.deleteSubcluster(scId1); @@ -407,7 +409,7 @@ describe('getSubclusterMethods', () => { ); const sc2AfterDelete = subclusterMethods.getSubcluster(scId2); expect(sc2AfterDelete).toBeDefined(); - expect(sc2AfterDelete?.vats).toContain(vatX); + expect(Object.values(sc2AfterDelete?.vats ?? {})).toContain(vatX); }); }); @@ -415,7 +417,7 @@ describe('getSubclusterMethods', () => { it('should return the subcluster ID for a given vat', () => { const scId = subclusterMethods.addSubcluster(mockClusterConfig1); const vatId: VatId = 'vTest'; - subclusterMethods.addSubclusterVat(scId, vatId); + subclusterMethods.addSubclusterVat(scId, 'test', vatId); expect(subclusterMethods.getVatSubcluster(vatId)).toBe(scId); }); @@ -438,14 +440,14 @@ describe('getSubclusterMethods', () => { const scId1 = subclusterMethods.addSubcluster(mockClusterConfig1); subclusterMethods.addSubcluster(mockClusterConfig2); const vatId = 'v1' as VatId; - subclusterMethods.addSubclusterVat(scId1, vatId); + subclusterMethods.addSubclusterVat(scId1, 'bob', vatId); subclusterMethods.clearEmptySubclusters(); const subclusters = subclusterMethods.getSubclusters(); expect(subclusters).toHaveLength(1); expect(subclusters[0]?.id).toBe(scId1); - expect(subclusters[0]?.vats).toContain(vatId); + expect(Object.values(subclusters[0]?.vats ?? {})).toContain(vatId); }); it('should do nothing if all subclusters have vats', () => { @@ -453,8 +455,8 @@ describe('getSubclusterMethods', () => { const scId2 = subclusterMethods.addSubcluster(mockClusterConfig2); const vatId1 = 'v1' as VatId; const vatId2 = 'v2' as VatId; - subclusterMethods.addSubclusterVat(scId1, vatId1); - subclusterMethods.addSubclusterVat(scId2, vatId2); + subclusterMethods.addSubclusterVat(scId1, 'bob', vatId1); + subclusterMethods.addSubclusterVat(scId2, 'alice', vatId2); const initialSubclusters = subclusterMethods.getSubclusters(); subclusterMethods.clearEmptySubclusters(); @@ -476,16 +478,16 @@ describe('getSubclusterMethods', () => { beforeEach(() => { scId = subclusterMethods.addSubcluster(mockClusterConfig1); - subclusterMethods.addSubclusterVat(scId, vatId1); - subclusterMethods.addSubclusterVat(scId, vatId2); + subclusterMethods.addSubclusterVat(scId, 'vat1', vatId1); + subclusterMethods.addSubclusterVat(scId, 'vat2', vatId2); }); it('should remove a vat from its subcluster', () => { subclusterMethods.removeVatFromSubcluster(vatId1); const subcluster = subclusterMethods.getSubcluster(scId); - expect(subcluster?.vats).not.toContain(vatId1); - expect(subcluster?.vats).toContain(vatId2); + expect(Object.values(subcluster?.vats ?? {})).not.toContain(vatId1); + expect(Object.values(subcluster?.vats ?? {})).toContain(vatId2); const mapRaw = mockVatToSubclusterMapStorage.get(); const map = mapRaw ? JSON.parse(mapRaw) : {}; @@ -505,7 +507,7 @@ describe('getSubclusterMethods', () => { subclusterMethods.removeVatFromSubcluster(vatId2); const subcluster = subclusterMethods.getSubcluster(scId); - expect(subcluster?.vats).toHaveLength(0); + expect(Object.keys(subcluster?.vats ?? {})).toHaveLength(0); const mapRaw = mockVatToSubclusterMapStorage.get(); const map = mapRaw ? JSON.parse(mapRaw) : {}; diff --git a/packages/ocap-kernel/src/store/methods/subclusters.ts b/packages/ocap-kernel/src/store/methods/subclusters.ts index 3838ed02e..809d04b82 100644 --- a/packages/ocap-kernel/src/store/methods/subclusters.ts +++ b/packages/ocap-kernel/src/store/methods/subclusters.ts @@ -74,7 +74,7 @@ export function getSubclusterMethods(ctx: StoreContext) { const newSubcluster: Subcluster = { id: newId, config, - vats: [], + vats: {}, }; currentSubclusters.push(newSubcluster); saveAllSubclustersToStorage(currentSubclusters); @@ -96,10 +96,15 @@ export function getSubclusterMethods(ctx: StoreContext) { * Adds a vat to the specified subcluster. * * @param subclusterId - The ID of the subcluster. + * @param vatName - The name of the vat within the subcluster. * @param vatId - The ID of the vat to add. * @throws If the subcluster is not found. */ - function addSubclusterVat(subclusterId: SubclusterId, vatId: VatId): void { + function addSubclusterVat( + subclusterId: SubclusterId, + vatName: string, + vatId: VatId, + ): void { const currentSubclusters = getSubclusters(); const subcluster = currentSubclusters.find((sc) => sc.id === subclusterId); @@ -118,8 +123,8 @@ export function getSubclusterMethods(ctx: StoreContext) { } // Add vat to subcluster if not already present - if (!subcluster.vats.includes(vatId)) { - subcluster.vats.push(vatId); + if (subcluster.vats[vatName] !== vatId) { + subcluster.vats[vatName] = vatId; } // Update the map and save all changes @@ -140,7 +145,7 @@ export function getSubclusterMethods(ctx: StoreContext) { if (!subcluster) { throw new SubclusterNotFoundError(subclusterId); } - return [...subcluster.vats]; + return Object.values(subcluster.vats); } /** @@ -153,11 +158,13 @@ export function getSubclusterMethods(ctx: StoreContext) { const currentSubclusters = getSubclusters(); const subcluster = currentSubclusters.find((sc) => sc.id === subclusterId); - // Remove vat from subcluster's vats array if subcluster exists + // Remove vat from subcluster's vats record if subcluster exists if (subcluster) { - const vatIndex = subcluster.vats.indexOf(vatId); - if (vatIndex > -1) { - subcluster.vats.splice(vatIndex, 1); + const entry = Object.entries(subcluster.vats).find( + ([, id]) => id === vatId, + ); + if (entry) { + delete subcluster.vats[entry[0]]; saveAllSubclustersToStorage(currentSubclusters); } } @@ -193,7 +200,7 @@ export function getSubclusterMethods(ctx: StoreContext) { // Remove all vats from the mapping const currentMap = getVatToSubclusterMap(); - const vatsToRemove = subclusterToDelete.vats.filter( + const vatsToRemove = Object.values(subclusterToDelete.vats).filter( (vatId) => currentMap[vatId] === subclusterId, ); @@ -222,7 +229,7 @@ export function getSubclusterMethods(ctx: StoreContext) { function clearEmptySubclusters(): void { const currentSubclusters = getSubclusters(); const nonEmptySubclusters = currentSubclusters.filter( - (sc) => sc.vats.length > 0, + (sc) => Object.keys(sc.vats).length > 0, ); if (nonEmptySubclusters.length !== currentSubclusters.length) { saveAllSubclustersToStorage(nonEmptySubclusters); diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 10319a9f2..ad4033ff2 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -430,7 +430,7 @@ export const isClusterConfig = (value: unknown): value is ClusterConfig => export const SubclusterStruct = object({ id: SubclusterIdStruct, config: ClusterConfigStruct, - vats: array(VatIdStruct), + vats: record(string(), VatIdStruct), }); export type Subcluster = Infer; diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index b199f764a..62eecf03d 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -36,7 +36,10 @@ describe('SubclusterManager', () => { ): Subcluster => ({ id, config, - vats: ['v1', 'v2'] as VatId[], + vats: { [`${config.bootstrap}`]: 'v1', vat2: 'v2' } as Record< + string, + VatId + >, }); beforeEach(() => { @@ -95,6 +98,7 @@ describe('SubclusterManager', () => { expect(mockKernelStore.addSubcluster).toHaveBeenCalledWith(config); expect(mockVatManager.launchVat).toHaveBeenCalledWith( config.vats.testVat, + 'testVat', 's1', ); expect(mockQueueMessage).toHaveBeenCalledWith('ko1', 'bootstrap', [ @@ -125,10 +129,12 @@ describe('SubclusterManager', () => { expect(mockVatManager.launchVat).toHaveBeenCalledTimes(2); expect(mockVatManager.launchVat).toHaveBeenCalledWith( config.vats.alice, + 'alice', 's1', ); expect(mockVatManager.launchVat).toHaveBeenCalledWith( config.vats.bob, + 'bob', 's1', ); }); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index 212886412..7a2a8dfbd 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -132,7 +132,7 @@ export class SubclusterManager { if (!subcluster) { throw new SubclusterNotFoundError(subclusterId); } - for (const vatId of subcluster.vats.reverse()) { + for (const vatId of Object.values(subcluster.vats).reverse()) { await this.#vatManager.terminateVat(vatId); this.#vatManager.collectGarbage(); } @@ -198,7 +198,7 @@ export class SubclusterManager { } // Delete vat configs and mark vats as terminated so their data will be cleaned up - for (const vatId of subcluster.vats) { + for (const vatId of Object.values(subcluster.vats)) { this.#kernelStore.deleteVatConfig(vatId); this.#kernelStore.markVatAsTerminated(vatId); } @@ -224,7 +224,11 @@ export class SubclusterManager { const rootIds: Record = {}; const roots: Record = {}; for (const [vatName, vatConfig] of Object.entries(config.vats)) { - const rootRef = await this.#vatManager.launchVat(vatConfig, subclusterId); + const rootRef = await this.#vatManager.launchVat( + vatConfig, + vatName, + subclusterId, + ); rootIds[vatName] = rootRef; roots[vatName] = kslot(rootRef, 'vatRoot'); } diff --git a/packages/ocap-kernel/src/vats/VatHandle.test.ts b/packages/ocap-kernel/src/vats/VatHandle.test.ts index 62dacc143..5e7090666 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.test.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.test.ts @@ -273,7 +273,7 @@ describe('VatHandle', () => { // terminate will remove the vat from the subcluster // so we need to add the vat to a subcluster mockKernelStore.addSubcluster({ bootstrap: 'test', vats: {} }); - mockKernelStore.addSubclusterVat('s1', 'v0'); + mockKernelStore.addSubclusterVat('s1', 'test', 'v0'); // Create a pending message that should be rejected on terminate const messagePromise = vat.sendVatCommand({ diff --git a/packages/ocap-kernel/src/vats/VatManager.test.ts b/packages/ocap-kernel/src/vats/VatManager.test.ts index 0e072ff4c..eb4318af8 100644 --- a/packages/ocap-kernel/src/vats/VatManager.test.ts +++ b/packages/ocap-kernel/src/vats/VatManager.test.ts @@ -162,9 +162,13 @@ describe('VatManager', () => { it('launches a new vat with subcluster', async () => { const config = createMockVatConfig(); - const kref = await vatManager.launchVat(config, 's1'); + const kref = await vatManager.launchVat(config, 'test', 's1'); - expect(mockKernelStore.addSubclusterVat).toHaveBeenCalledWith('s1', 'v1'); + expect(mockKernelStore.addSubclusterVat).toHaveBeenCalledWith( + 's1', + 'test', + 'v1', + ); expect(kref).toBe('ko1'); }); }); diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 7947c9dc4..9f242789d 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -81,10 +81,15 @@ export class VatManager { * Launch a new vat. * * @param vatConfig - Configuration for the new vat. + * @param vatName - The name of the vat within the subcluster. * @param subclusterId - The ID of the subcluster to launch the vat in. Optional. * @returns a promise for the KRef of the new vat's root object. */ - async launchVat(vatConfig: VatConfig, subclusterId?: string): Promise { + async launchVat( + vatConfig: VatConfig, + vatName: string, + subclusterId?: string, + ): Promise { const vatId = this.#kernelStore.getNextVatId(); await this.runVat(vatId, vatConfig); this.#kernelStore.initEndpoint(vatId); @@ -94,7 +99,7 @@ export class VatManager { ); this.#kernelStore.setVatConfig(vatId, vatConfig); if (subclusterId) { - this.#kernelStore.addSubclusterVat(subclusterId, vatId); + this.#kernelStore.addSubclusterVat(subclusterId, vatName, vatId); } return rootRef; } From 4f0810be3a3d376e9f93570d50101c9f6b502435 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:51:49 -0800 Subject: [PATCH 31/47] fix(kernel-browser-runtime): Wrap mock methods to survive harden() in CapTP integration test Delegate each vi.fn() mock through a wrapper function before passing to makeKernelFacet, so harden() freezes the wrappers instead of the original mock instances, keeping vitest call tracking intact. Co-Authored-By: Claude Opus 4.6 --- .../captp/captp.integration.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts index 92b3ec17f..4e4ff0161 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts @@ -53,9 +53,21 @@ describe('CapTP Integration', () => { provideFacet: vi.fn(), } as unknown as Kernel; - // Wire up provideFacet to return a real facet backed by the mock kernel + // Wire up provideFacet to return a real facet backed by the mock kernel. + // We wrap each mock method with a delegate so that harden() inside + // makeKernelFacet (via makeDefaultExo) freezes the wrapper functions + // instead of the original vi.fn() instances, keeping call tracking intact. vi.mocked(mockKernel.provideFacet).mockReturnValue( - makeKernelFacet(mockKernel), + makeKernelFacet( + Object.fromEntries( + Object.entries(mockKernel) + .filter( + (entry): entry is [string, (...args: never[]) => unknown] => + typeof entry[1] === 'function', + ) + .map(([key, fn]) => [key, (...args: never[]) => fn(...args)]), + ) as unknown as Kernel, + ), ); // Wire up CapTP endpoints to dispatch messages synchronously to each other From 40f0b33d34280095dd72f79ec81f7af605bad647 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:57:48 -0800 Subject: [PATCH 32/47] refactor(omnium): Add SystemSubclusterConfig type constraint --- packages/omnium-gatherum/src/offscreen.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 2a4abc2be..a3540d11a 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -8,6 +8,7 @@ import { import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import type { SystemSubclusterConfig } from '@metamask/ocap-kernel'; import type { DuplexStream } from '@metamask/streams'; import { initializeMessageChannel, @@ -88,7 +89,7 @@ async function makeKernelWorker(): Promise< services: ['kernelFacet'], }, }, - ]; + ] satisfies SystemSubclusterConfig[]; workerUrlParams.set('system-subclusters', JSON.stringify(systemSubclusters)); const workerUrl = new URL('kernel-worker.js', import.meta.url); From 7a8551eb9a35192027c2de98e3e461accd9ad268 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:01:24 -0800 Subject: [PATCH 33/47] fix(ocap-kernel): Update system subcluster mappings on reload reloadSubcluster() creates a new subcluster with a new ID, but was not updating #systemSubclusterRoots or the persisted systemSubcluster.* mappings. This left stale mappings that caused 'has no bootstrap vat' errors on subsequent kernel restarts. Co-Authored-By: Claude Opus 4.6 --- .../nodejs/test/e2e/system-subcluster.test.ts | 53 +++++++++++++++++++ packages/ocap-kernel/src/Kernel.ts | 41 +++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/nodejs/test/e2e/system-subcluster.test.ts b/packages/nodejs/test/e2e/system-subcluster.test.ts index 2fc2c90f5..ce81832a6 100644 --- a/packages/nodejs/test/e2e/system-subcluster.test.ts +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -357,6 +357,59 @@ describe('System Subcluster', { timeout: 30_000 }, () => { }); }); + describe('system subcluster reload', () => { + it('updates system subcluster mapping after reload and survives restart', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, true, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + const initialSubclusters = kernel.getSubclusters(); + expect(initialSubclusters).toHaveLength(1); + const initialSubclusterId = initialSubclusters[0]!.id; + const initialRoot = kernel.getSystemSubclusterRoot('test-system'); + + // Reload the system subcluster (creates a new subcluster with new ID) + const reloadedSubcluster = + await kernel.reloadSubcluster(initialSubclusterId); + expect(reloadedSubcluster.id).not.toBe(initialSubclusterId); + + // System subcluster root should be updated + const reloadedRoot = kernel.getSystemSubclusterRoot('test-system'); + expect(reloadedRoot).toBeDefined(); + expect(reloadedRoot).not.toBe(initialRoot); + + // The reloaded system vat should still work + const result = await kernel.queueMessage( + reloadedRoot, + 'hasKernelFacet', + [], + ); + await delay(); + expect(kunser(result)).toBe(true); + + // Stop and restart kernel — should not throw + await kernel.stop(); + // eslint-disable-next-line require-atomic-updates + kernel = undefined; + + // eslint-disable-next-line require-atomic-updates + kernel = await makeTestKernel(kernelDatabase, false, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + // Should restore with the new subcluster ID, not the stale one + const restartedSubclusters = kernel.getSubclusters(); + expect(restartedSubclusters).toHaveLength(1); + expect(restartedSubclusters[0]!.id).toBe(reloadedSubcluster.id); + + const restartedRoot = kernel.getSystemSubclusterRoot('test-system'); + expect(restartedRoot).toBeDefined(); + }); + }); + describe('multiple system subclusters', () => { it('launches multiple system subclusters at kernel initialization', async () => { kernelDatabase = await makeSQLKernelDatabase({ diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 333a958bd..38e8c4865 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -493,7 +493,46 @@ export class Kernel { * @throws If the subcluster is not found. */ async reloadSubcluster(subclusterId: string): Promise { - return this.#subclusterManager.reloadSubcluster(subclusterId); + // Check if this is a system subcluster before reloading + let systemSubclusterName: string | undefined; + for (const [ + name, + mappedId, + ] of this.#kernelStore.getAllSystemSubclusterMappings()) { + if (mappedId === subclusterId) { + systemSubclusterName = name; + break; + } + } + + const newSubcluster = + await this.#subclusterManager.reloadSubcluster(subclusterId); + + // Update system subcluster mappings to point to the new subcluster ID + if (systemSubclusterName !== undefined) { + const bootstrapVatId = newSubcluster.vats[newSubcluster.config.bootstrap]; + if (!bootstrapVatId) { + throw new Error( + `Reloaded system subcluster "${systemSubclusterName}" has no bootstrap vat`, + ); + } + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (!rootKref) { + throw new Error( + `Reloaded system subcluster "${systemSubclusterName}" has no root object`, + ); + } + this.#systemSubclusterRoots.set(systemSubclusterName, rootKref); + this.#kernelStore.setSystemSubclusterMapping( + systemSubclusterName, + newSubcluster.id, + ); + this.#logger.info( + `Updated system subcluster mapping "${systemSubclusterName}" to ${newSubcluster.id}`, + ); + } + + return newSubcluster; } /** From 59ba8f2f196707404aad5b9d2632801f36127a80 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:20:34 -0800 Subject: [PATCH 34/47] refactor(ocap-kernel): Move system subcluster lifecycle from Kernel to SubclusterManager System subcluster state and logic (persist/restore/cleanup mappings, launch new named subclusters, track roots) belongs in SubclusterManager which already owns subcluster CRUD, termination, and reload. This moves ~140 lines out of Kernel.ts into SubclusterManager, keeping Kernel as a thin orchestration layer that delegates to its managers. Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/Kernel.ts | 184 +-------- .../src/vats/SubclusterManager.test.ts | 353 +++++++++++++++++- .../ocap-kernel/src/vats/SubclusterManager.ts | 196 ++++++++++ 3 files changed, 560 insertions(+), 173 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 38e8c4865..ceaf6262f 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -54,9 +54,6 @@ export class Kernel { /** Manages subcluster operations */ readonly #subclusterManager: SubclusterManager; - /** Stores bootstrap root krefs of launched system subclusters */ - readonly #systemSubclusterRoots: Map = new Map(); - /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -157,6 +154,7 @@ export class Kernel { getKernelService: (name) => this.#kernelServiceManager.getKernelService(name), queueMessage: this.queueMessage.bind(this), + logger: this.#logger.subLogger({ tags: ['SubclusterManager'] }), }); this.#kernelRouter = new KernelRouter( @@ -223,22 +221,18 @@ export class Kernel { ): Promise { const configs = systemSubclusterConfigs ?? []; - // Validate no duplicate system subcluster names - const names = new Set(configs.map((config) => config.name)); - if (names.size !== configs.length) { - throw new Error('Duplicate system subcluster names in config'); - } - // Set up the remote message handler this.#remoteManager.setMessageHandler( async (from: string, message: string) => this.#remoteManager.handleRemoteMessage(from, message), ); - // Handle persisted system subclusters before initializing vats: - // - Delete orphaned ones (no longer in config) so their vats aren't started - // - Restore mappings for existing ones (registers kernelFacet if needed) - this.#handlePersistedSystemSubclusters(configs); + // Restore persisted system subclusters before initializing vats + // (so orphaned vats aren't started) + if (configs.length > 0) { + this.provideFacet(); + } + this.#subclusterManager.initSystemSubclusters(configs); // Start all vats that were previously running before starting the queue // This ensures that any messages in the queue have their target vats ready @@ -256,109 +250,8 @@ export class Kernel { // Don't re-throw to avoid unhandled rejection in this long-running task }); - // Launch any NEW system subclusters (requires queue to be running) - // It's safe to do this last because messages and existing vats cannot possibly - // be targeting these new subclusters. - await this.#launchNewSystemSubclusters(configs); - } - - /** - * Handle persisted system subclusters before vat initialization. - * - Deletes orphaned subclusters (no longer in config) so their vats aren't started - * - Restores mappings for existing subclusters (registers kernelFacet if needed) - * - * @param configs - Array of system subcluster configurations. - */ - #handlePersistedSystemSubclusters(configs: SystemSubclusterConfig[]): void { - const persistedMappings = - this.#kernelStore.getAllSystemSubclusterMappings(); - - if (persistedMappings.size === 0) { - return; - } - - const configNames = new Set(configs.map((config) => config.name)); - - // Track if we have any valid persisted subclusters to restore - let hasValidPersistedSubclusters = false; - - for (const [name, subclusterId] of persistedMappings) { - if (!configNames.has(name)) { - // This system subcluster no longer has a config - delete it - this.#logger.info( - `System subcluster "${name}" no longer in config, deleting`, - ); - this.#subclusterManager.deleteSubcluster(subclusterId); - this.#kernelStore.deleteSystemSubclusterMapping(name); - continue; - } - - // Subcluster has a config - try to restore it - const subcluster = this.getSubcluster(subclusterId); - if (!subcluster) { - this.#logger.warn( - `System subcluster "${name}" mapping points to non-existent subcluster ${subclusterId}, cleaning up`, - ); - this.#kernelStore.deleteSystemSubclusterMapping(name); - continue; - } - - const bootstrapVatId = subcluster.vats[subcluster.config.bootstrap]; - if (!bootstrapVatId) { - throw new Error( - `System subcluster "${name}" has no bootstrap vat - database may be corrupted`, - ); - } - - const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); - if (!rootKref) { - throw new Error( - `System subcluster "${name}" has no root object - database may be corrupted`, - ); - } - - // Register kernelFacet on first valid persisted subcluster - if (!hasValidPersistedSubclusters) { - this.provideFacet(); - hasValidPersistedSubclusters = true; - } - - this.#systemSubclusterRoots.set(name, rootKref); - this.#logger.info(`Restored system subcluster "${name}"`); - } - } - - /** - * Launch new system subclusters that aren't already in persistence. - * This must be called after the kernel queue is running since launchSubcluster - * sends bootstrap messages. - * - * @param configs - Array of system subcluster configurations. - */ - async #launchNewSystemSubclusters( - configs: SystemSubclusterConfig[], - ): Promise { - // Filter to only configs that weren't restored from persistence - const newConfigs = configs.filter( - ({ name }) => !this.#systemSubclusterRoots.has(name), - ); - - if (newConfigs.length === 0) { - return; - } - - // Ensure kernelFacet is registered for new system subclusters - this.provideFacet(); - - for (const { name, config } of newConfigs) { - const result = await this.launchSubcluster(config); - this.#systemSubclusterRoots.set(name, result.rootKref); - - // Persist the mapping - this.#kernelStore.setSystemSubclusterMapping(name, result.subclusterId); - - this.#logger.info(`Launched new system subcluster "${name}"`); - } + // Launch new system subclusters (requires queue to be running) + await this.#subclusterManager.launchNewSystemSubclusters(configs); } /** @@ -471,16 +364,6 @@ export class Kernel { * @returns A promise that resolves when termination is complete. */ async terminateSubcluster(subclusterId: string): Promise { - // Check if this is a system subcluster and clean up the mapping - const mappings = this.#kernelStore.getAllSystemSubclusterMappings(); - for (const [name, mappedSubclusterId] of mappings) { - if (mappedSubclusterId === subclusterId) { - this.#systemSubclusterRoots.delete(name); - this.#kernelStore.deleteSystemSubclusterMapping(name); - this.#logger.info(`Cleaned up system subcluster mapping "${name}"`); - break; - } - } return this.#subclusterManager.terminateSubcluster(subclusterId); } @@ -493,46 +376,7 @@ export class Kernel { * @throws If the subcluster is not found. */ async reloadSubcluster(subclusterId: string): Promise { - // Check if this is a system subcluster before reloading - let systemSubclusterName: string | undefined; - for (const [ - name, - mappedId, - ] of this.#kernelStore.getAllSystemSubclusterMappings()) { - if (mappedId === subclusterId) { - systemSubclusterName = name; - break; - } - } - - const newSubcluster = - await this.#subclusterManager.reloadSubcluster(subclusterId); - - // Update system subcluster mappings to point to the new subcluster ID - if (systemSubclusterName !== undefined) { - const bootstrapVatId = newSubcluster.vats[newSubcluster.config.bootstrap]; - if (!bootstrapVatId) { - throw new Error( - `Reloaded system subcluster "${systemSubclusterName}" has no bootstrap vat`, - ); - } - const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); - if (!rootKref) { - throw new Error( - `Reloaded system subcluster "${systemSubclusterName}" has no root object`, - ); - } - this.#systemSubclusterRoots.set(systemSubclusterName, rootKref); - this.#kernelStore.setSystemSubclusterMapping( - systemSubclusterName, - newSubcluster.id, - ); - this.#logger.info( - `Updated system subcluster mapping "${systemSubclusterName}" to ${newSubcluster.id}`, - ); - } - - return newSubcluster; + return this.#subclusterManager.reloadSubcluster(subclusterId); } /** @@ -566,11 +410,7 @@ export class Kernel { * @throws If the system subcluster is not found. */ getSystemSubclusterRoot(name: string): KRef { - const kref = this.#systemSubclusterRoots.get(name); - if (kref === undefined) { - throw new Error(`System subcluster "${name}" not found`); - } - return kref; + return this.#subclusterManager.getSystemSubclusterRoot(name); } /** @@ -782,7 +622,7 @@ export class Kernel { await this.#kernelQueue.waitForCrank(); try { await this.terminateAllVats(); - this.#systemSubclusterRoots.clear(); + this.#subclusterManager.clearSystemSubclusters(); this.#resetKernelState(); } catch (error) { this.#logger.error('Error resetting kernel:', error); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 62eecf03d..a3e0fe9ab 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -5,7 +5,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { KernelQueue } from '../KernelQueue.ts'; import type { KernelStore } from '../store/index.ts'; -import type { VatId, KRef, ClusterConfig, Subcluster } from '../types.ts'; +import type { + VatId, + KRef, + ClusterConfig, + Subcluster, + SystemSubclusterConfig, +} from '../types.ts'; import { SubclusterManager } from './SubclusterManager.ts'; import type { VatManager } from './VatManager.ts'; @@ -50,6 +56,12 @@ describe('SubclusterManager', () => { getSubclusterVats: vi.fn().mockReturnValue([]), deleteSubcluster: vi.fn(), getVatSubcluster: vi.fn().mockReturnValue('s1'), + getAllSystemSubclusterMappings: vi.fn().mockReturnValue(new Map()), + deleteSystemSubclusterMapping: vi.fn(), + setSystemSubclusterMapping: vi.fn(), + getRootObject: vi.fn(), + deleteVatConfig: vi.fn(), + markVatAsTerminated: vi.fn(), } as unknown as Mocked; mockKernelQueue = { @@ -463,4 +475,343 @@ describe('SubclusterManager', () => { expect(mockKernelStore.addSubcluster).toHaveBeenCalledOnce(); }); }); + + describe('initSystemSubclusters', () => { + const makeSystemConfig = (name: string): SystemSubclusterConfig => ({ + name, + config: createMockClusterConfig(name), + }); + + it('validates no duplicate names', () => { + const configs = [makeSystemConfig('dup'), makeSystemConfig('dup')]; + + expect(() => subclusterManager.initSystemSubclusters(configs)).toThrow( + 'Duplicate system subcluster names in config', + ); + }); + + it('accepts configs with no persisted mappings', () => { + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue(new Map()); + + subclusterManager.initSystemSubclusters([makeSystemConfig('sys1')]); + + expect(mockKernelStore.getAllSystemSubclusterMappings).toHaveBeenCalled(); + }); + + it('deletes orphaned system subclusters no longer in config', () => { + const subcluster = createMockSubcluster('s1', createMockClusterConfig()); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['orphan', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + + // Pass empty configs - the persisted "orphan" is not in config + subclusterManager.initSystemSubclusters([]); + + expect(mockKernelStore.deleteVatConfig).toHaveBeenCalled(); + expect(mockKernelStore.markVatAsTerminated).toHaveBeenCalled(); + expect(mockKernelStore.deleteSubcluster).toHaveBeenCalledWith('s1'); + expect( + mockKernelStore.deleteSystemSubclusterMapping, + ).toHaveBeenCalledWith('orphan'); + }); + + it('restores valid persisted system subclusters', () => { + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko1'); + + subclusterManager.initSystemSubclusters([makeSystemConfig('sys')]); + + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko1'); + }); + + it('cleans up mapping when subcluster no longer exists', () => { + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's99']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(undefined); + + subclusterManager.initSystemSubclusters([makeSystemConfig('sys')]); + + expect( + mockKernelStore.deleteSystemSubclusterMapping, + ).toHaveBeenCalledWith('sys'); + }); + + it('throws when persisted system subcluster has no bootstrap vat', () => { + const config = createMockClusterConfig('sys'); + // Subcluster with empty vats - no bootstrap vat + const subcluster: Subcluster = { id: 's1', config, vats: {} }; + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + + expect(() => + subclusterManager.initSystemSubclusters([makeSystemConfig('sys')]), + ).toThrow('has no bootstrap vat - database may be corrupted'); + }); + + it('throws when persisted system subcluster has no root object', () => { + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue(undefined); + + expect(() => + subclusterManager.initSystemSubclusters([makeSystemConfig('sys')]), + ).toThrow('has no root object - database may be corrupted'); + }); + }); + + describe('launchNewSystemSubclusters', () => { + const makeSystemConfig = (name: string): SystemSubclusterConfig => ({ + name, + config: createMockClusterConfig(name), + }); + + it('launches configs not already restored from persistence', async () => { + // First restore a persisted subcluster + const config = createMockClusterConfig('existing'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['existing', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko-existing'); + + subclusterManager.initSystemSubclusters([ + makeSystemConfig('existing'), + makeSystemConfig('newOne'), + ]); + + // Now launch new ones — "existing" should be skipped + mockKernelStore.addSubcluster.mockReturnValue('s2'); + await subclusterManager.launchNewSystemSubclusters([ + makeSystemConfig('existing'), + makeSystemConfig('newOne'), + ]); + + // launchVat should have been called once for the new subcluster + expect(mockVatManager.launchVat).toHaveBeenCalledOnce(); + expect(mockKernelStore.setSystemSubclusterMapping).toHaveBeenCalledWith( + 'newOne', + 's2', + ); + }); + + it('does nothing when all configs are already restored', async () => { + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko1'); + + subclusterManager.initSystemSubclusters([makeSystemConfig('sys')]); + + await subclusterManager.launchNewSystemSubclusters([ + makeSystemConfig('sys'), + ]); + + expect(mockVatManager.launchVat).not.toHaveBeenCalled(); + }); + + it('persists mappings for newly launched subclusters', async () => { + mockKernelStore.addSubcluster.mockReturnValue('s1'); + + await subclusterManager.launchNewSystemSubclusters([ + makeSystemConfig('newSys'), + ]); + + expect(mockKernelStore.setSystemSubclusterMapping).toHaveBeenCalledWith( + 'newSys', + 's1', + ); + expect(subclusterManager.getSystemSubclusterRoot('newSys')).toBe('ko1'); + }); + }); + + describe('getSystemSubclusterRoot', () => { + it('returns kref for a known system subcluster', () => { + // Set up state via initSystemSubclusters + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko42'); + + subclusterManager.initSystemSubclusters([ + { name: 'sys', config: createMockClusterConfig('sys') }, + ]); + + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko42'); + }); + + it('throws for unknown system subcluster name', () => { + expect(() => + subclusterManager.getSystemSubclusterRoot('unknown'), + ).toThrow('System subcluster "unknown" not found'); + }); + }); + + describe('clearSystemSubclusters', () => { + it('clears all system subcluster root state', () => { + // Set up state via initSystemSubclusters + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko42'); + + subclusterManager.initSystemSubclusters([ + { name: 'sys', config: createMockClusterConfig('sys') }, + ]); + + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko42'); + + subclusterManager.clearSystemSubclusters(); + + expect(() => subclusterManager.getSystemSubclusterRoot('sys')).toThrow( + 'System subcluster "sys" not found', + ); + }); + }); + + describe('terminateSubcluster with system subcluster mapping', () => { + it('cleans up system subcluster mapping when terminating a system subcluster', async () => { + // Set up a system subcluster via init + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getRootObject.mockReturnValue('ko1'); + mockKernelStore.getSubclusterVats.mockReturnValue([ + 'v1', + 'v2', + ] as VatId[]); + + subclusterManager.initSystemSubclusters([ + { name: 'sys', config: createMockClusterConfig('sys') }, + ]); + + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko1'); + + await subclusterManager.terminateSubcluster('s1'); + + expect( + mockKernelStore.deleteSystemSubclusterMapping, + ).toHaveBeenCalledWith('sys'); + expect(() => subclusterManager.getSystemSubclusterRoot('sys')).toThrow( + 'System subcluster "sys" not found', + ); + }); + + it('does not clean up mappings for non-system subclusters', async () => { + const subcluster = createMockSubcluster('s1', createMockClusterConfig()); + mockKernelStore.getSubcluster.mockReturnValue(subcluster); + mockKernelStore.getSubclusterVats.mockReturnValue([]); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue(new Map()); + + await subclusterManager.terminateSubcluster('s1'); + + expect( + mockKernelStore.deleteSystemSubclusterMapping, + ).not.toHaveBeenCalled(); + }); + }); + + describe('reloadSubcluster with system subcluster mapping', () => { + it('updates system subcluster mapping after reload', async () => { + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + const newSubcluster = { ...subcluster, id: 's2' }; + + // First call: getSubcluster for the existing subcluster + // Second call: getAllSystemSubclusterMappings iteration calls getSubcluster + // Third call: getSubcluster for the new subcluster after reload + mockKernelStore.getSubcluster + .mockReturnValueOnce(subcluster) // reloadSubcluster initial check + .mockReturnValueOnce(newSubcluster); // getSubcluster(newId) after launch + mockKernelStore.addSubcluster.mockReturnValue('s2'); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.getRootObject.mockReturnValue('ko-new'); + + // Set up initial state + mockKernelStore.getSubcluster.mockReturnValueOnce(subcluster); + mockKernelStore.getRootObject.mockReturnValueOnce('ko-old'); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValueOnce( + new Map([['sys', 's1']]), + ); + + // Reset mocks and set up the reload scenario properly + mockKernelStore.getSubcluster.mockReset(); + mockKernelStore.getRootObject.mockReset(); + mockKernelStore.getAllSystemSubclusterMappings.mockReset(); + + // For initSystemSubclusters + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValueOnce( + new Map([['sys', 's1']]), + ); + mockKernelStore.getSubcluster.mockReturnValueOnce(subcluster); + mockKernelStore.getRootObject.mockReturnValueOnce('ko-old'); + + subclusterManager.initSystemSubclusters([ + { name: 'sys', config: createMockClusterConfig('sys') }, + ]); + + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko-old'); + + // For reloadSubcluster + mockKernelStore.getSubcluster.mockReturnValueOnce(subcluster); // initial getSubcluster + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValueOnce( + new Map([['sys', 's1']]), + ); // system subcluster check + mockKernelStore.addSubcluster.mockReturnValue('s2'); + mockKernelStore.getSubcluster.mockReturnValueOnce(newSubcluster); // after launch + mockKernelStore.getRootObject.mockReturnValueOnce('ko-new'); // new root + + const result = await subclusterManager.reloadSubcluster('s1'); + + expect(result.id).toBe('s2'); + expect(mockKernelStore.setSystemSubclusterMapping).toHaveBeenCalledWith( + 'sys', + 's2', + ); + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko-new'); + }); + + it('does not update mappings for non-system subclusters', async () => { + const config = createMockClusterConfig(); + const subcluster = createMockSubcluster('s1', config); + const newSubcluster = { ...subcluster, id: 's2' }; + + mockKernelStore.getSubcluster + .mockReturnValueOnce(subcluster) + .mockReturnValueOnce(newSubcluster); + mockKernelStore.addSubcluster.mockReturnValue('s2'); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue(new Map()); + + await subclusterManager.reloadSubcluster('s1'); + + expect(mockKernelStore.setSystemSubclusterMapping).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index 7a2a8dfbd..8e2c4d202 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -1,5 +1,6 @@ import type { CapData } from '@endo/marshal'; import { SubclusterNotFoundError } from '@metamask/kernel-errors'; +import { Logger } from '@metamask/logger'; import type { KernelQueue } from '../KernelQueue.ts'; import type { VatManager } from './VatManager.ts'; @@ -12,6 +13,7 @@ import type { ClusterConfig, Subcluster, SubclusterLaunchResult, + SystemSubclusterConfig, } from '../types.ts'; import { isClusterConfig } from '../types.ts'; import { Fail } from '../utils/assert.ts'; @@ -26,6 +28,7 @@ type SubclusterManagerOptions = { method: string, args: unknown[], ) => Promise>; + logger?: Logger; }; /** @@ -51,6 +54,12 @@ export class SubclusterManager { args: unknown[], ) => Promise>; + /** Logger for diagnostic output */ + readonly #logger: Logger; + + /** Stores bootstrap root krefs of launched system subclusters */ + readonly #systemSubclusterRoots: Map = new Map(); + /** * Creates a new SubclusterManager instance. * @@ -60,6 +69,7 @@ export class SubclusterManager { * @param options.vatManager - Manager for creating and managing vat instances. * @param options.getKernelService - Function to retrieve a kernel service by its kref. * @param options.queueMessage - Function to queue messages for delivery to targets. + * @param options.logger - Optional logger for diagnostic output. */ constructor({ kernelStore, @@ -67,12 +77,14 @@ export class SubclusterManager { vatManager, getKernelService, queueMessage, + logger, }: SubclusterManagerOptions) { this.#kernelStore = kernelStore; this.#kernelQueue = kernelQueue; this.#vatManager = vatManager; this.#getKernelService = getKernelService; this.#queueMessage = queueMessage; + this.#logger = logger ?? new Logger('SubclusterManager'); harden(this); } @@ -110,6 +122,18 @@ export class SubclusterManager { if (!this.#kernelStore.getSubcluster(subclusterId)) { throw new SubclusterNotFoundError(subclusterId); } + + // Clean up system subcluster mapping if this is a system subcluster + const mappings = this.#kernelStore.getAllSystemSubclusterMappings(); + for (const [name, mappedSubclusterId] of mappings) { + if (mappedSubclusterId === subclusterId) { + this.#systemSubclusterRoots.delete(name); + this.#kernelStore.deleteSystemSubclusterMapping(name); + this.#logger.info(`Cleaned up system subcluster mapping "${name}"`); + break; + } + } + const vatIdsToTerminate = this.#kernelStore.getSubclusterVats(subclusterId); for (const vatId of vatIdsToTerminate.reverse()) { await this.#vatManager.terminateVat(vatId); @@ -132,6 +156,19 @@ export class SubclusterManager { if (!subcluster) { throw new SubclusterNotFoundError(subclusterId); } + + // Check if this is a system subcluster before reloading + let systemSubclusterName: string | undefined; + for (const [ + name, + mappedId, + ] of this.#kernelStore.getAllSystemSubclusterMappings()) { + if (mappedId === subclusterId) { + systemSubclusterName = name; + break; + } + } + for (const vatId of Object.values(subcluster.vats).reverse()) { await this.#vatManager.terminateVat(vatId); this.#vatManager.collectGarbage(); @@ -142,6 +179,31 @@ export class SubclusterManager { if (!newSubcluster) { throw new SubclusterNotFoundError(newId); } + + // Update system subcluster mappings to point to the new subcluster ID + if (systemSubclusterName !== undefined) { + const bootstrapVatId = newSubcluster.vats[newSubcluster.config.bootstrap]; + if (!bootstrapVatId) { + throw new Error( + `Reloaded system subcluster "${systemSubclusterName}" has no bootstrap vat`, + ); + } + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (!rootKref) { + throw new Error( + `Reloaded system subcluster "${systemSubclusterName}" has no root object`, + ); + } + this.#systemSubclusterRoots.set(systemSubclusterName, rootKref); + this.#kernelStore.setSystemSubclusterMapping( + systemSubclusterName, + newSubcluster.id, + ); + this.#logger.info( + `Updated system subcluster mapping "${systemSubclusterName}" to ${newSubcluster.id}`, + ); + } + return newSubcluster; } @@ -261,6 +323,140 @@ export class SubclusterManager { return { rootKref, bootstrapResult }; } + /** + * Initialize system subclusters from persisted state. + * Validates no duplicate names, deletes orphaned subclusters, and restores + * mappings for existing ones. Must be called before vat initialization. + * + * @param configs - Array of system subcluster configurations. + */ + initSystemSubclusters(configs: SystemSubclusterConfig[]): void { + // Validate no duplicate system subcluster names + const names = new Set(configs.map((config) => config.name)); + if (names.size !== configs.length) { + throw new Error('Duplicate system subcluster names in config'); + } + + this.#restorePersistedSystemSubclusters(configs); + } + + /** + * Launch new system subclusters that aren't already in persistence. + * This must be called after the kernel queue is running since launchSubcluster + * sends bootstrap messages. + * + * @param configs - Array of system subcluster configurations. + */ + async launchNewSystemSubclusters( + configs: SystemSubclusterConfig[], + ): Promise { + // Filter to only configs that weren't restored from persistence + const newConfigs = configs.filter( + ({ name }) => !this.#systemSubclusterRoots.has(name), + ); + + if (newConfigs.length === 0) { + return; + } + + for (const { name, config } of newConfigs) { + const result = await this.launchSubcluster(config); + this.#systemSubclusterRoots.set(name, result.rootKref); + + // Persist the mapping + this.#kernelStore.setSystemSubclusterMapping(name, result.subclusterId); + + this.#logger.info(`Launched new system subcluster "${name}"`); + } + } + + /** + * Get the bootstrap root kref of a system subcluster by name. + * + * @param name - The name of the system subcluster. + * @returns The bootstrap root kref. + * @throws If the system subcluster is not found. + */ + getSystemSubclusterRoot(name: string): KRef { + const kref = this.#systemSubclusterRoots.get(name); + if (kref === undefined) { + throw new Error(`System subcluster "${name}" not found`); + } + return kref; + } + + /** + * Clear all system subcluster root state. + * Called by the kernel during reset. + */ + clearSystemSubclusters(): void { + this.#systemSubclusterRoots.clear(); + } + + /** + * Restore persisted system subclusters before vat initialization. + * - Deletes orphaned subclusters (no longer in config) so their vats aren't started + * - Restores mappings for existing subclusters + * + * @param configs - Array of system subcluster configurations. + * @returns Whether any valid persisted subclusters were restored. + */ + #restorePersistedSystemSubclusters( + configs: SystemSubclusterConfig[], + ): boolean { + const persistedMappings = + this.#kernelStore.getAllSystemSubclusterMappings(); + + if (persistedMappings.size === 0) { + return false; + } + + const configNames = new Set(configs.map((config) => config.name)); + let hasValidPersistedSubclusters = false; + + for (const [name, subclusterId] of persistedMappings) { + if (!configNames.has(name)) { + // This system subcluster no longer has a config - delete it + this.#logger.info( + `System subcluster "${name}" no longer in config, deleting`, + ); + this.deleteSubcluster(subclusterId); + this.#kernelStore.deleteSystemSubclusterMapping(name); + continue; + } + + // Subcluster has a config - try to restore it + const subcluster = this.getSubcluster(subclusterId); + if (!subcluster) { + this.#logger.warn( + `System subcluster "${name}" mapping points to non-existent subcluster ${subclusterId}, cleaning up`, + ); + this.#kernelStore.deleteSystemSubclusterMapping(name); + continue; + } + + const bootstrapVatId = subcluster.vats[subcluster.config.bootstrap]; + if (!bootstrapVatId) { + throw new Error( + `System subcluster "${name}" has no bootstrap vat - database may be corrupted`, + ); + } + + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (!rootKref) { + throw new Error( + `System subcluster "${name}" has no root object - database may be corrupted`, + ); + } + + this.#systemSubclusterRoots.set(name, rootKref); + hasValidPersistedSubclusters = true; + this.#logger.info(`Restored system subcluster "${name}"`); + } + + return hasValidPersistedSubclusters; + } + /** * Reload all subclusters. * This is for debugging purposes only. From ba08a8f2709f044badb2aa82b6092dfaae7808ef Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:33:48 -0800 Subject: [PATCH 35/47] fix(extension): Update hardcoded ko IDs in e2e tests for kernelFacet registration The kernelFacet kernel service now takes ko3, shifting all vat root ko IDs by 1. Update hardcoded ko references in control-panel, object-registry, and remote-comms e2e tests accordingly. Co-Authored-By: Claude Opus 4.6 --- .../extension/test/e2e/control-panel.test.ts | 18 +++++++++--------- .../extension/test/e2e/object-registry.test.ts | 2 +- .../extension/test/e2e/remote-comms.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/extension/test/e2e/control-panel.test.ts b/packages/extension/test/e2e/control-panel.test.ts index 8677aeb5d..bbe59c316 100644 --- a/packages/extension/test/e2e/control-panel.test.ts +++ b/packages/extension/test/e2e/control-panel.test.ts @@ -191,19 +191,19 @@ test.describe('Control Panel', () => { const v3Values = [ '{"key":"e.nextPromiseId.v3","value":"2"}', '{"key":"e.nextObjectId.v3","value":"1"}', - '{"key":"ko5.owner","value":"v3"}', - '{"key":"v3.c.ko5","value":"R o+0"}', - '{"key":"v3.c.o+0","value":"ko5"}', + '{"key":"ko6.owner","value":"v3"}', + '{"key":"v3.c.ko6","value":"R o+0"}', + '{"key":"v3.c.o+0","value":"ko6"}', '{"key":"v3.c.kp4","value":"R p-1"}', '{"key":"v3.c.p-1","value":"kp4"}', - '{"key":"ko5.refCount","value":"1,1"}', + '{"key":"ko6.refCount","value":"1,1"}', '{"key":"kp4.refCount","value":"2"}', ]; const v1koValues = [ - '{"key":"v1.c.ko4","value":"R o-1"}', - '{"key":"v1.c.o-1","value":"ko4"}', - '{"key":"v1.c.ko5","value":"R o-2"}', - '{"key":"v1.c.o-2","value":"ko5"}', + '{"key":"v1.c.ko5","value":"R o-1"}', + '{"key":"v1.c.o-1","value":"ko5"}', + '{"key":"v1.c.ko6","value":"R o-2"}', + '{"key":"v1.c.o-2","value":"ko6"}', ]; await expect( popupPage.locator('[data-testid="message-output"]'), @@ -263,7 +263,7 @@ test.describe('Control Panel', () => { popupPage.locator('[data-testid="message-output"]'), ).not.toContainText(value); } - // ko3 (vat root) reference still exists for v1 + // v2/v3 vat root references still exist for v1 for (const value of v1koValues) { await expect( popupPage.locator('[data-testid="message-output"]'), diff --git a/packages/extension/test/e2e/object-registry.test.ts b/packages/extension/test/e2e/object-registry.test.ts index f54038a4a..5e869bfee 100644 --- a/packages/extension/test/e2e/object-registry.test.ts +++ b/packages/extension/test/e2e/object-registry.test.ts @@ -108,7 +108,7 @@ test.describe('Object Registry', () => { test('should revoke an object', async () => { const owner = 'v1'; - const v1Root = 'ko3'; + const v1Root = 'ko4'; const [target, method, params] = [v1Root, 'hello', '["Bob"]']; // Before revoking, we should be able to send a message to the object diff --git a/packages/extension/test/e2e/remote-comms.test.ts b/packages/extension/test/e2e/remote-comms.test.ts index b07c3935c..a56c30b86 100644 --- a/packages/extension/test/e2e/remote-comms.test.ts +++ b/packages/extension/test/e2e/remote-comms.test.ts @@ -118,8 +118,8 @@ test.describe('Remote Communications', () => { await expect(targetSelect).toBeVisible(); const options = await targetSelect.locator('option').all(); expect(options.length).toBeGreaterThan(1); - await targetSelect.selectOption({ value: 'ko3' }); - expect(await targetSelect.inputValue()).toBe('ko3'); + await targetSelect.selectOption({ value: 'ko4' }); + expect(await targetSelect.inputValue()).toBe('ko4'); // Set method to doRunRun (the remote communication method) const methodInput = popupPage1.locator('[data-testid="message-method"]'); From 3af3e2eb51a3efe1c08edfe43ee33d9fec0322cb Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:46:41 -0800 Subject: [PATCH 36/47] fix(ocap-kernel): Update system subcluster mappings in reloadAllSubclusters reloadAllSubclusters bypasses reloadSubcluster and has its own loop that calls addSubcluster + launchVatsForSubcluster directly, so it never updated the in-memory systemSubclusterRoots map or persisted mappings. After a reload-all, getSystemSubclusterRoot() would return stale krefs pointing to deleted objects. Co-Authored-By: Claude Opus 4.6 --- .../src/vats/SubclusterManager.test.ts | 22 ++++++++++++ .../ocap-kernel/src/vats/SubclusterManager.ts | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index a3e0fe9ab..123c91539 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -474,6 +474,28 @@ describe('SubclusterManager', () => { expect(mockVatManager.terminateAllVats).toHaveBeenCalledOnce(); expect(mockKernelStore.addSubcluster).toHaveBeenCalledOnce(); }); + + it('updates system subcluster mappings after reload', async () => { + const config = createMockClusterConfig('sys'); + const subcluster = createMockSubcluster('s1', config); + const newSubcluster = { ...subcluster, id: 's3' }; + + mockKernelStore.getSubclusters.mockReturnValue([subcluster]); + mockKernelStore.getAllSystemSubclusterMappings.mockReturnValue( + new Map([['sys', 's1']]), + ); + mockKernelStore.addSubcluster.mockReturnValue('s3'); + mockKernelStore.getSubcluster.mockReturnValue(newSubcluster); + mockKernelStore.getRootObject.mockReturnValue('ko-new'); + + await subclusterManager.reloadAllSubclusters(); + + expect(mockKernelStore.setSystemSubclusterMapping).toHaveBeenCalledWith( + 'sys', + 's3', + ); + expect(subclusterManager.getSystemSubclusterRoot('sys')).toBe('ko-new'); + }); }); describe('initSystemSubclusters', () => { diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index 8e2c4d202..e443657cb 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -463,11 +463,47 @@ export class SubclusterManager { */ async reloadAllSubclusters(): Promise { const subclusters = this.#kernelStore.getSubclusters(); + // Build a reverse map of old subcluster ID -> system subcluster name + const systemNameBySubclusterId = new Map(); + for (const [ + name, + mappedId, + ] of this.#kernelStore.getAllSystemSubclusterMappings()) { + systemNameBySubclusterId.set(mappedId, name); + } + await this.#vatManager.terminateAllVats(); for (const subcluster of subclusters) { await this.#kernelQueue.waitForCrank(); const newId = this.#kernelStore.addSubcluster(subcluster.config); await this.#launchVatsForSubcluster(newId, subcluster.config); + + // Update system subcluster mappings if this was a system subcluster + const systemName = systemNameBySubclusterId.get(subcluster.id); + if (systemName !== undefined) { + const newSubcluster = this.getSubcluster(newId); + if (!newSubcluster) { + throw new SubclusterNotFoundError(newId); + } + const bootstrapVatId = + newSubcluster.vats[newSubcluster.config.bootstrap]; + if (!bootstrapVatId) { + throw new Error( + `Reloaded system subcluster "${systemName}" has no bootstrap vat`, + ); + } + const rootKref = this.#kernelStore.getRootObject(bootstrapVatId); + if (!rootKref) { + throw new Error( + `Reloaded system subcluster "${systemName}" has no root object`, + ); + } + this.#systemSubclusterRoots.set(systemName, rootKref); + this.#kernelStore.setSystemSubclusterMapping(systemName, newId); + this.#logger.info( + `Updated system subcluster mapping "${systemName}" to ${newId}`, + ); + } } } } From 9ce537974cca23d9dd79eac858d48d483d49cad8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:24:45 -0800 Subject: [PATCH 37/47] refactor(omnium): Use proper Baggage type and simplify adapter Replace local Baggage type definition with the one exported from ocap-kernel, which includes keys() for native iteration. This eliminates the manual __storage_keys__ tracking in the baggage adapter. Also replace local LaunchResult type with SubclusterLaunchResult from ocap-kernel, and remove dead resuscitation guard in controller-vat bootstrap. Co-Authored-By: Claude Opus 4.6 --- packages/omnium-gatherum/src/background.ts | 2 +- .../controllers/caplet/caplet-controller.ts | 16 +++-- .../src/controllers/caplet/index.ts | 1 - .../src/controllers/caplet/types.ts | 9 --- .../omnium-gatherum/src/controllers/index.ts | 1 - .../src/vats/controller-vat.ts | 40 ++++--------- .../src/vats/storage/baggage-adapter.test.ts | 26 ++------ .../src/vats/storage/baggage-adapter.ts | 59 ++----------------- 8 files changed, 35 insertions(+), 119 deletions(-) diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 47157c862..26109af4d 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -141,7 +141,7 @@ async function main(): Promise { type GlobalSetters = { setKernel: (kernel: KernelFacet | Promise) => void; - // Not actually globally available + // Not globally available, but needed for other globals to work setControllerVatKref: (kref: string) => void; }; diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 2fb728b44..a1b542ca3 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -1,13 +1,15 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Logger } from '@metamask/logger'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; +import type { + ClusterConfig, + SubclusterLaunchResult, +} from '@metamask/ocap-kernel'; import type { CapletId, CapletManifest, InstalledCaplet, InstallResult, - LaunchResult, } from './types.ts'; import { isCapletManifest } from './types.ts'; import { Controller } from '../base-controller.ts'; @@ -85,7 +87,7 @@ export type CapletControllerDeps = { /** Storage adapter for creating controller storage */ adapter: StorageAdapter; /** Launch a subcluster for a caplet */ - launchSubcluster: (config: ClusterConfig) => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; /** Terminate a caplet's subcluster */ terminateSubcluster: (subclusterId: string) => Promise; /** Get the root object for a vat by kref string */ @@ -107,7 +109,9 @@ export class CapletController extends Controller< > { readonly #pendingInstalls: Set = new Set(); - readonly #launchSubcluster: (config: ClusterConfig) => Promise; + readonly #launchSubcluster: ( + config: ClusterConfig, + ) => Promise; readonly #terminateSubcluster: (subclusterId: string) => Promise; @@ -126,7 +130,9 @@ export class CapletController extends Controller< private constructor( storage: ControllerStorage, logger: Logger | undefined, - launchSubcluster: (config: ClusterConfig) => Promise, + launchSubcluster: ( + config: ClusterConfig, + ) => Promise, terminateSubcluster: (subclusterId: string) => Promise, getVatRoot: (krefString: string) => Promise, ) { diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts index af216b869..13aa85b12 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/index.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -4,7 +4,6 @@ export type { CapletManifest, InstalledCaplet, InstallResult, - LaunchResult, } from './types.ts'; export { isCapletId, diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.ts b/packages/omnium-gatherum/src/controllers/caplet/types.ts index 512dd98de..c94bf12e9 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/types.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -99,12 +99,3 @@ export type InstallResult = { capletId: CapletId; subclusterId: string; }; - -/** - * Result of launching a subcluster. - * This is the interface expected by CapletController's deps. - */ -export type LaunchResult = { - subclusterId: string; - rootKref: string; -}; diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index 2f5b78ade..b62bae95d 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -16,7 +16,6 @@ export type { CapletManifest, InstalledCaplet, InstallResult, - LaunchResult, CapletControllerState, CapletControllerFacet, CapletControllerDeps, diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 2d9efc4eb..57bf33409 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -5,17 +5,14 @@ import type { PromiseKit } from '@endo/promise-kit'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Logger } from '@metamask/logger'; import type { + Baggage, ClusterConfig, SubclusterLaunchResult, } from '@metamask/ocap-kernel'; import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; -import type { Baggage } from './storage/baggage-adapter.ts'; import { CapletController } from '../controllers/caplet/caplet-controller.ts'; -import type { - CapletControllerFacet, - LaunchResult, -} from '../controllers/caplet/index.ts'; +import type { CapletControllerFacet } from '../controllers/caplet/index.ts'; import type { StorageAdapter } from '../controllers/storage/types.ts'; /** @@ -67,13 +64,8 @@ async function initializeCapletController(options: { adapter: storageAdapter, launchSubcluster: async ( config: ClusterConfig, - ): Promise => { - const result = await E(kernelFacet).launchSubcluster(config); - return { - subclusterId: result.subclusterId, - rootKref: result.rootKref, - }; - }, + ): Promise => + E(kernelFacet).launchSubcluster(config), terminateSubcluster: async (subclusterId: string): Promise => E(kernelFacet).terminateSubcluster(subclusterId), getVatRoot: async (krefString: string): Promise => @@ -117,16 +109,18 @@ export function buildRootObject( }: PromiseKit = makePromiseKit(); // Restore kernelFacet from baggage if available (for resuscitation) - const kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') + const persistedKernelFacet: KernelFacet | undefined = baggage.has( + 'kernelFacet', + ) ? (baggage.get('kernelFacet') as KernelFacet) : undefined; // If we have a persisted kernelFacet, initialize the controller immediately - if (kernelFacet) { + if (persistedKernelFacet) { logger?.info('Restoring controller from baggage'); // Fire-and-forget: the promise kit will be resolved/rejected when initialization completes initializeCapletController({ - kernelFacet, + kernelFacet: persistedKernelFacet, storageAdapter, vatLogger, resolve: resolveCapletFacet, @@ -156,31 +150,23 @@ export function buildRootObject( _vats: unknown, services: BootstrapServices, ): Promise { - // Skip if already initialized from baggage (resuscitation case) - if (kernelFacet) { - logger?.info('Bootstrap called but already restored from baggage'); - return; - } - logger?.info('Bootstrap called'); - const { kernelFacet: newKernelFacet } = services; - if (!newKernelFacet) { + const { kernelFacet } = services; + if (!kernelFacet) { throw new Error('kernelFacet service is required'); } // Store in baggage for persistence across restarts - baggage.init('kernelFacet', newKernelFacet); + baggage.init('kernelFacet', kernelFacet); await initializeCapletController({ - kernelFacet: newKernelFacet, + kernelFacet, storageAdapter, vatLogger, resolve: resolveCapletFacet, reject: rejectCapletFacet, }); - - logger?.info('Bootstrap complete'); }, ...capletMethods, diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts index 247d90d8c..c4aa556b4 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -1,3 +1,4 @@ +import type { Baggage } from '@metamask/ocap-kernel'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { makeBaggageStorageAdapter } from './baggage-adapter.ts'; @@ -27,6 +28,7 @@ function makeMockBaggage() { delete: vi.fn((key: string) => { store.delete(key); }), + keys: vi.fn(() => store.keys()), _store: store, // For test inspection }; } @@ -37,7 +39,7 @@ describe('makeBaggageStorageAdapter', () => { beforeEach(() => { baggage = makeMockBaggage(); - adapter = makeBaggageStorageAdapter(baggage); + adapter = makeBaggageStorageAdapter(baggage as unknown as Baggage); }); describe('get', () => { @@ -56,10 +58,8 @@ describe('makeBaggageStorageAdapter', () => { await adapter.set('to-delete', { data: 'test' }); await adapter.delete('to-delete'); - // Baggage should no longer have the key expect(baggage._store.has('to-delete')).toBe(false); - // Adapter should return undefined const result = await adapter.get('to-delete'); expect(result).toBeUndefined(); }); @@ -73,36 +73,22 @@ describe('makeBaggageStorageAdapter', () => { }); it('updates existing key', async () => { - // First set await adapter.set('existing-key', { value: 1 }); - // Second set should use baggage.set, not init await adapter.set('existing-key', { value: 2 }); expect(baggage.set).toHaveBeenCalled(); expect(baggage._store.get('existing-key')).toStrictEqual({ value: 2 }); }); - it('tracks keys', async () => { - await adapter.set('key1', 'value1'); - await adapter.set('key2', 'value2'); - - const keys = await adapter.keys(); - expect(keys).toContain('key1'); - expect(keys).toContain('key2'); - }); - - it('re-adds previously deleted key to tracking', async () => { - // Set initial value + it('re-sets previously deleted key', async () => { await adapter.set('reused-key', { original: true }); expect(await adapter.keys()).toContain('reused-key'); - // Delete it await adapter.delete('reused-key'); expect(await adapter.keys()).not.toContain('reused-key'); expect(baggage._store.has('reused-key')).toBe(false); - // Set again - should re-add to tracking await adapter.set('reused-key', { restored: true }); expect(await adapter.keys()).toContain('reused-key'); expect(await adapter.get('reused-key')).toStrictEqual({ restored: true }); @@ -110,7 +96,7 @@ describe('makeBaggageStorageAdapter', () => { }); describe('delete', () => { - it('removes key from baggage and key list', async () => { + it('removes key from baggage', async () => { await adapter.set('to-delete', { data: 'test' }); await adapter.delete('to-delete'); @@ -121,8 +107,8 @@ describe('makeBaggageStorageAdapter', () => { }); it('does nothing for non-existent key', async () => { - // Should not throw and keys should remain empty await adapter.delete('nonexistent'); + expect(baggage.delete).not.toHaveBeenCalled(); const keys = await adapter.keys(); expect(keys).toStrictEqual([]); }); diff --git a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts index 451fb8523..939c02f9c 100644 --- a/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -1,58 +1,16 @@ +import type { Baggage } from '@metamask/ocap-kernel'; import type { Json } from '@metamask/utils'; import type { StorageAdapter } from '../../controllers/storage/types.ts'; -/** - * Baggage interface from liveslots. - * Baggage provides durable storage for vat state. - */ -export type Baggage = { - has: (key: string) => boolean; - get: (key: string) => unknown; - init: (key: string, value: unknown) => void; - set: (key: string, value: unknown) => void; - delete: (key: string) => void; -}; - -const KEYS_KEY = '__storage_keys__'; - /** * Create a StorageAdapter implementation backed by vat baggage. * Provides synchronous persistence (baggage writes are durable). * - * Since baggage doesn't support key enumeration directly, we track - * stored keys in a separate baggage entry. - * * @param baggage - The vat baggage store. * @returns A StorageAdapter backed by baggage. */ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { - /** - * Get all tracked storage keys. - * - * @returns The set of tracked keys. - */ - const getKeys = (): Set => { - if (baggage.has(KEYS_KEY)) { - return new Set(baggage.get(KEYS_KEY) as string[]); - } - return new Set(); - }; - - /** - * Save the set of tracked keys to baggage. - * - * @param keys - The set of keys to save. - */ - const saveKeys = (keys: Set): void => { - const arr = Array.from(keys); - if (baggage.has(KEYS_KEY)) { - baggage.set(KEYS_KEY, harden(arr)); - } else { - baggage.init(KEYS_KEY, harden(arr)); - } - }; - return harden({ async get(key: string): Promise { if (baggage.has(key)) { @@ -62,34 +20,25 @@ export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { }, async set(key: string, value: Json): Promise { - const keys = getKeys(); if (baggage.has(key)) { baggage.set(key, harden(value)); } else { baggage.init(key, harden(value)); } - // Always ensure key is tracked (handles re-adding after delete) - if (!keys.has(key)) { - keys.add(key); - saveKeys(keys); - } }, async delete(key: string): Promise { if (baggage.has(key)) { baggage.delete(key); - const keys = getKeys(); - keys.delete(key); - saveKeys(keys); } }, async keys(prefix?: string): Promise { - const allKeys = getKeys(); + const allKeys = [...baggage.keys()]; if (!prefix) { - return Array.from(allKeys); + return allKeys; } - return Array.from(allKeys).filter((k) => k.startsWith(prefix)); + return allKeys.filter((k) => k.startsWith(prefix)); }, }); } From a6374173ed2bba04927f59af2348ab73c5d0807b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:38:12 -0800 Subject: [PATCH 38/47] refactor(omnium): Make logger non-optional in controllers Construct a fallback Logger in controller-vat when vatPowers.logger is not provided, ensuring a real Logger is always passed downstream. This makes the logger property non-optional in ControllerConfig, Controller, ControllerStorage, and CapletController, eliminating optional chaining on logger calls throughout. Co-Authored-By: Claude Opus 4.6 --- .../src/controllers/base-controller.ts | 12 ++++----- .../controllers/caplet/caplet-controller.ts | 14 +++++------ .../controllers/storage/controller-storage.ts | 8 +++--- .../src/vats/controller-vat.ts | 25 ++++++++++--------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts index c2ff095ca..d53c1eb3a 100644 --- a/packages/omnium-gatherum/src/controllers/base-controller.ts +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -13,7 +13,7 @@ export type ControllerMethods = Record unknown>; * Configuration passed to all controllers during initialization. */ export type ControllerConfig = { - logger?: Logger | undefined; + logger: Logger; }; /** @@ -59,19 +59,19 @@ export abstract class Controller< readonly #storage: ControllerStorage; - readonly #logger: Logger | undefined; + readonly #logger: Logger; /** * Protected constructor - subclasses must call this via super(). * * @param name - Controller name for debugging/logging. * @param storage - ControllerStorage instance for state management. - * @param logger - Optional logger instance. + * @param logger - Logger instance. */ protected constructor( name: ControllerName, storage: ControllerStorage, - logger?: Logger, + logger: Logger, ) { this.#name = name; this.#storage = storage; @@ -101,9 +101,9 @@ export abstract class Controller< /** * Logger instance for this controller. * - * @returns The logger instance, or undefined if not provided. + * @returns The logger instance. */ - protected get logger(): Logger | undefined { + protected get logger(): Logger { return this.#logger; } diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index a1b542ca3..90b1c1076 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -121,7 +121,7 @@ export class CapletController extends Controller< * Private constructor - use static create() method. * * @param storage - ControllerStorage for caplet state. - * @param logger - Optional logger instance. + * @param logger - Logger instance. * @param launchSubcluster - Function to launch a subcluster. * @param terminateSubcluster - Function to terminate a subcluster. * @param getVatRoot - Function to get a vat's root object as a presence. @@ -129,7 +129,7 @@ export class CapletController extends Controller< // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors private constructor( storage: ControllerStorage, - logger: Logger | undefined, + logger: Logger, launchSubcluster: ( config: ClusterConfig, ) => Promise, @@ -159,7 +159,7 @@ export class CapletController extends Controller< namespace: 'caplet', adapter: deps.adapter, makeDefaultState: () => ({ caplets: {} }), - logger: config.logger?.subLogger({ tags: ['storage'] }), + logger: config.logger.subLogger({ tags: ['storage'] }), }); const controller = new CapletController( @@ -209,7 +209,7 @@ export class CapletController extends Controller< _bundle?: unknown, ): Promise { const { id } = manifest; - this.logger?.info(`Installing caplet: ${id}`); + this.logger.info(`Installing caplet: ${id}`); // TODO: Move this validation in front of the controller. if (!isCapletManifest(manifest)) { @@ -247,7 +247,7 @@ export class CapletController extends Controller< }; }); - this.logger?.info( + this.logger.info( `Caplet ${id} installed with subcluster ${subclusterId}`, ); return { capletId: id, subclusterId }; @@ -262,7 +262,7 @@ export class CapletController extends Controller< * @param capletId - The ID of the caplet to uninstall. */ async #uninstall(capletId: CapletId): Promise { - this.logger?.info(`Uninstalling caplet: ${capletId}`); + this.logger.info(`Uninstalling caplet: ${capletId}`); const caplet = this.state.caplets[capletId]; if (caplet === undefined) { @@ -275,7 +275,7 @@ export class CapletController extends Controller< delete draft.caplets[capletId]; }); - this.logger?.info(`Caplet ${capletId} uninstalled`); + this.logger.info(`Caplet ${capletId} uninstalled`); } /** diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts index e0eb428c2..a86e90197 100644 --- a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -22,8 +22,8 @@ export type ControllerStorageConfig> = { adapter: StorageAdapter; /** Default state values - used for initialization and type inference */ makeDefaultState: () => State; - /** Optional logger for storage operations */ - logger?: Logger | undefined; + /** Logger for storage operations */ + logger: Logger; /** Debounce delay in milliseconds (default: 100, set to 0 for tests) */ debounceMs?: number; }; @@ -57,7 +57,7 @@ export class ControllerStorage> { readonly #makeDefaultState: () => State; - readonly #logger: Logger | undefined; + readonly #logger: Logger; readonly #debounceMs: number; @@ -251,7 +251,7 @@ export class ControllerStorage> { // Persist current state values for accumulated keys this.#persistAccumulatedKeys(this.#state, keysToWrite).catch((error) => { - this.#logger?.error('Failed to persist state changes:', error); + this.#logger.error('Failed to persist state changes:', error); }); } diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 57bf33409..33fc05d57 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -3,7 +3,7 @@ import type { ERef } from '@endo/eventual-send'; import { makePromiseKit } from '@endo/promise-kit'; import type { PromiseKit } from '@endo/promise-kit'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import type { Logger } from '@metamask/logger'; +import { Logger } from '@metamask/logger'; import type { Baggage, ClusterConfig, @@ -44,22 +44,22 @@ type BootstrapServices = { * @param options - Initialization options. * @param options.kernelFacet - The kernel facet for kernel operations. * @param options.storageAdapter - The storage adapter for persistence. - * @param options.vatLogger - Optional logger for the vat. + * @param options.logger - Logger for the vat. * @param options.resolve - Function to resolve the caplet facet promise. * @param options.reject - Function to reject the caplet facet promise. */ async function initializeCapletController(options: { kernelFacet: KernelFacet; storageAdapter: StorageAdapter; - vatLogger: Logger | undefined; + logger: Logger; resolve: (facet: CapletControllerFacet) => void; reject: (error: unknown) => void; }): Promise { - const { kernelFacet, storageAdapter, vatLogger, resolve, reject } = options; + const { kernelFacet, storageAdapter, logger, resolve, reject } = options; try { const capletFacet = await CapletController.make( - { logger: vatLogger?.subLogger({ tags: ['caplet'] }) }, + { logger: logger.subLogger({ tags: ['caplet'] }) }, { adapter: storageAdapter, launchSubcluster: async ( @@ -95,8 +95,9 @@ export function buildRootObject( _parameters: unknown, baggage: Baggage, ): object { - const vatLogger = vatPowers.logger?.subLogger({ tags: ['bootstrap'] }); - const logger = vatLogger ?? console; + const logger = (vatPowers.logger ?? new Logger()).subLogger({ + tags: ['controller-vat'], + }); // Create baggage-backed storage adapter const storageAdapter = makeBaggageStorageAdapter(baggage); @@ -117,16 +118,16 @@ export function buildRootObject( // If we have a persisted kernelFacet, initialize the controller immediately if (persistedKernelFacet) { - logger?.info('Restoring controller from baggage'); + logger.info('Restoring controller from baggage'); // Fire-and-forget: the promise kit will be resolved/rejected when initialization completes initializeCapletController({ kernelFacet: persistedKernelFacet, storageAdapter, - vatLogger, + logger, resolve: resolveCapletFacet, reject: rejectCapletFacet, }).catch((error) => { - logger?.error('Failed to restore controller from baggage:', error); + logger.error('Failed to restore controller from baggage:', error); }); } @@ -150,7 +151,7 @@ export function buildRootObject( _vats: unknown, services: BootstrapServices, ): Promise { - logger?.info('Bootstrap called'); + logger.info('Bootstrap called'); const { kernelFacet } = services; if (!kernelFacet) { @@ -163,7 +164,7 @@ export function buildRootObject( await initializeCapletController({ kernelFacet, storageAdapter, - vatLogger, + logger, resolve: resolveCapletFacet, reject: rejectCapletFacet, }); From 8002ee58d800d5245de5c22cc1295a6f663f629e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:47:24 -0800 Subject: [PATCH 39/47] refactor(ocap-kernel): Simplify makeKernelFacet to bind methods internally Instead of the caller manually binding each method, makeKernelFacet now takes the kernel instance directly and iterates over a const array of method names to bind them. This reduces the call site in Kernel.ts from 12 lines to 1. Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/Kernel.ts | 18 ++------ packages/ocap-kernel/src/kernel-facet.test.ts | 12 ++--- packages/ocap-kernel/src/kernel-facet.ts | 45 +++++++++++-------- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index ceaf6262f..615331ecc 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -227,8 +227,8 @@ export class Kernel { this.#remoteManager.handleRemoteMessage(from, message), ); - // Restore persisted system subclusters before initializing vats - // (so orphaned vats aren't started) + // Restore persisted system subclusters and delete ones that no + // longer have a config, to ensure that orphaned vats aren't started if (configs.length > 0) { this.provideFacet(); } @@ -266,19 +266,7 @@ export class Kernel { return existing.service as KernelFacet; } - const kernelFacet = makeKernelFacet({ - getPresence: this.getPresence.bind(this), - getStatus: this.getStatus.bind(this), - getSubcluster: this.getSubcluster.bind(this), - getSubclusters: this.getSubclusters.bind(this), - getSystemSubclusterRoot: this.getSystemSubclusterRoot.bind(this), - launchSubcluster: this.launchSubcluster.bind(this), - pingVat: this.pingVat.bind(this), - queueMessage: this.queueMessage.bind(this), - reloadSubcluster: this.reloadSubcluster.bind(this), - reset: this.reset.bind(this), - terminateSubcluster: this.terminateSubcluster.bind(this), - }); + const kernelFacet = makeKernelFacet(this); this.#kernelServiceManager.registerKernelServiceObject( 'kernelFacet', kernelFacet, diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index fa31d82d9..ceb1df646 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; -import type { KernelFacetDependencies } from './kernel-facet.ts'; +import type { KernelFacetSource } from './kernel-facet.ts'; import { makeKernelFacet } from './kernel-facet.ts'; import { kslot } from './liveslots/kernel-marshal.ts'; -const makeDeps = (): KernelFacetDependencies => ({ +const makeMockKernel = (): KernelFacetSource => ({ getPresence: async (kref: string, iface: string = 'Kernel Object') => kslot(kref, iface), getStatus: async () => Promise.resolve({ vats: [], subclusters: [] }), @@ -23,7 +23,7 @@ const makeDeps = (): KernelFacetDependencies => ({ Promise.resolve({ id: 's1', config: { bootstrap: 'b', vats: {} }, - vats: [], + vats: {}, }), reset: async () => Promise.resolve(), terminateSubcluster: async () => Promise.resolve(), @@ -31,7 +31,7 @@ const makeDeps = (): KernelFacetDependencies => ({ describe('makeKernelFacet', () => { it('creates an exo with all dependency methods and ping', () => { - const facet = makeKernelFacet(makeDeps()); + const facet = makeKernelFacet(makeMockKernel()); expect(typeof facet.getPresence).toBe('function'); expect(typeof facet.getStatus).toBe('function'); @@ -48,12 +48,12 @@ describe('makeKernelFacet', () => { }); it('ping returns "pong"', () => { - const facet = makeKernelFacet(makeDeps()); + const facet = makeKernelFacet(makeMockKernel()); expect(facet.ping()).toBe('pong'); }); it('delegates dependency methods to the provided functions', async () => { - const facet = makeKernelFacet(makeDeps()); + const facet = makeKernelFacet(makeMockKernel()); expect(facet.getSystemSubclusterRoot('test')).toBe('ko99'); expect(await facet.getStatus()).toStrictEqual({ diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index f0bc8f7e6..eda82db70 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -2,22 +2,26 @@ import { makeDefaultExo } from '@metamask/kernel-utils'; import type { Kernel } from './Kernel.ts'; +const kernelFacetMethodNames = [ + 'getPresence', + 'getStatus', + 'getSubcluster', + 'getSubclusters', + 'getSystemSubclusterRoot', + 'launchSubcluster', + 'pingVat', + 'queueMessage', + 'reloadSubcluster', + 'reset', + 'terminateSubcluster', +] as const; + /** - * Dependencies required to create a kernel facet. + * The subset of Kernel that the kernel facet exposes. */ -export type KernelFacetDependencies = Pick< +export type KernelFacetSource = Pick< Kernel, - | 'getPresence' - | 'getStatus' - | 'getSubcluster' - | 'getSubclusters' - | 'getSystemSubclusterRoot' - | 'launchSubcluster' - | 'pingVat' - | 'queueMessage' - | 'reloadSubcluster' - | 'reset' - | 'terminateSubcluster' + (typeof kernelFacetMethodNames)[number] >; /** @@ -26,7 +30,7 @@ export type KernelFacetDependencies = Pick< * This is the interface provided as a vatpower to the bootstrap vat of a * system vat. It enables privileged kernel operations. */ -export type KernelFacet = KernelFacetDependencies & { +export type KernelFacet = KernelFacetSource & { /** * Ping the kernel. * @@ -38,14 +42,19 @@ export type KernelFacet = KernelFacetDependencies & { /** * Creates a kernel facet exo that provides privileged kernel operations. * - * All methods except ping() are delegated directly from the kernel. + * Binds each delegated method to the kernel instance so that private field + * access works correctly when the methods are called through the exo. * - * @param deps - Bound kernel methods to expose on the facet. + * @param kernel - The kernel instance to bind methods from. * @returns The kernel facet exo. */ -export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { +export function makeKernelFacet(kernel: KernelFacetSource): KernelFacet { + const bound: Record = {}; + for (const name of kernelFacetMethodNames) { + bound[name] = kernel[name].bind(kernel); + } return makeDefaultExo('kernelFacet', { - ...deps, + ...bound, ping: () => 'pong' as const, }) as KernelFacet; } From a9ab2c2c7e299d8bc63ec8859a25515e0d9eee41 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:59:40 -0800 Subject: [PATCH 40/47] refactor(nodejs): Use options bag for makeTestKernel Replace the positional (resetStorage, mnemonicOrOptions) parameters with a single options object. resetStorage defaults to true since nearly every call site uses that value. Co-Authored-By: Claude Opus 4.6 --- .../test/e2e/bip39-identity-recovery.test.ts | 24 ++++++++------ packages/nodejs/test/e2e/remote-comms.test.ts | 16 +++++---- .../nodejs/test/e2e/system-subcluster.test.ts | 33 ++++++++++--------- packages/nodejs/test/helpers/kernel.ts | 19 +++++------ packages/nodejs/test/helpers/remote-comms.ts | 2 +- 5 files changed, 52 insertions(+), 42 deletions(-) diff --git a/packages/nodejs/test/e2e/bip39-identity-recovery.test.ts b/packages/nodejs/test/e2e/bip39-identity-recovery.test.ts index f7e006614..4856c4687 100644 --- a/packages/nodejs/test/e2e/bip39-identity-recovery.test.ts +++ b/packages/nodejs/test/e2e/bip39-identity-recovery.test.ts @@ -32,7 +32,7 @@ describe('BIP39 Identity Recovery', () => { let peerId1: string | undefined; try { - kernel1 = await makeTestKernel(kernelDatabase1, true); + kernel1 = await makeTestKernel(kernelDatabase1); await kernel1.initRemoteComms({ relays: DUMMY_RELAYS, mnemonic: TEST_MNEMONIC, @@ -56,7 +56,7 @@ describe('BIP39 Identity Recovery', () => { let kernel2: Kernel | undefined; try { - kernel2 = await makeTestKernel(kernelDatabase2, true); + kernel2 = await makeTestKernel(kernelDatabase2); await kernel2.initRemoteComms({ relays: DUMMY_RELAYS, mnemonic: TEST_MNEMONIC, @@ -88,7 +88,7 @@ describe('BIP39 Identity Recovery', () => { let peerId1: string | undefined; try { - kernel1 = await makeTestKernel(kernelDatabase1, true); + kernel1 = await makeTestKernel(kernelDatabase1); await kernel1.initRemoteComms({ relays: DUMMY_RELAYS, mnemonic: TEST_MNEMONIC, @@ -111,7 +111,7 @@ describe('BIP39 Identity Recovery', () => { let kernel2: Kernel | undefined; try { - kernel2 = await makeTestKernel(kernelDatabase2, true); + kernel2 = await makeTestKernel(kernelDatabase2); await kernel2.initRemoteComms({ relays: DUMMY_RELAYS, mnemonic: DIFFERENT_MNEMONIC, @@ -142,7 +142,7 @@ describe('BIP39 Identity Recovery', () => { try { // First kernel without mnemonic - generates random identity - kernel = await makeTestKernel(kernelDatabase, true); + kernel = await makeTestKernel(kernelDatabase); await kernel.initRemoteComms({ relays: DUMMY_RELAYS }); const status1 = await kernel.getStatus(); @@ -153,7 +153,7 @@ describe('BIP39 Identity Recovery', () => { kernel = undefined; // Create kernel with mnemonic but using existing storage - should throw - kernel = await makeTestKernel(kernelDatabase, false); // resetStorage = false + kernel = await makeTestKernel(kernelDatabase, { resetStorage: false }); await expect( kernel.initRemoteComms({ relays: DUMMY_RELAYS, @@ -181,7 +181,7 @@ describe('BIP39 Identity Recovery', () => { let kernel: Kernel | undefined; try { - kernel = await makeTestKernel(kernelDatabase, true); + kernel = await makeTestKernel(kernelDatabase); await expect( kernel.initRemoteComms({ @@ -209,7 +209,7 @@ describe('BIP39 Identity Recovery', () => { try { // First kernel without mnemonic - generates random identity - kernel = await makeTestKernel(kernelDatabase, true); + kernel = await makeTestKernel(kernelDatabase); await kernel.initRemoteComms({ relays: DUMMY_RELAYS }); const status1 = await kernel.getStatus(); @@ -221,7 +221,9 @@ describe('BIP39 Identity Recovery', () => { kernel = undefined; // Create kernel with resetStorage AND mnemonic - should work - kernel = await makeTestKernel(kernelDatabase, true, TEST_MNEMONIC); + kernel = await makeTestKernel(kernelDatabase, { + mnemonic: TEST_MNEMONIC, + }); await kernel.initRemoteComms({ relays: DUMMY_RELAYS }); const status2 = await kernel.getStatus(); @@ -235,7 +237,9 @@ describe('BIP39 Identity Recovery', () => { await kernel.stop(); kernel = undefined; - kernel = await makeTestKernel(kernelDatabase, true, TEST_MNEMONIC); + kernel = await makeTestKernel(kernelDatabase, { + mnemonic: TEST_MNEMONIC, + }); await kernel.initRemoteComms({ relays: DUMMY_RELAYS }); const status3 = await kernel.getStatus(); diff --git a/packages/nodejs/test/e2e/remote-comms.test.ts b/packages/nodejs/test/e2e/remote-comms.test.ts index ae236db29..3a1469519 100644 --- a/packages/nodejs/test/e2e/remote-comms.test.ts +++ b/packages/nodejs/test/e2e/remote-comms.test.ts @@ -76,8 +76,8 @@ describe.sequential('Remote Communications E2E', () => { }); kernelStore2 = makeKernelStore(kernelDatabase2); - kernel1 = await makeTestKernel(kernelDatabase1, true); - kernel2 = await makeTestKernel(kernelDatabase2, true); + kernel1 = await makeTestKernel(kernelDatabase1); + kernel2 = await makeTestKernel(kernelDatabase2); }); afterEach(async () => { @@ -231,7 +231,9 @@ describe.sequential('Remote Communications E2E', () => { // Kill the server and restart it await serverKernel.stop(); - serverKernel = await makeTestKernel(kernelDatabase2, false); + serverKernel = await makeTestKernel(kernelDatabase2, { + resetStorage: false, + }); await serverKernel.initRemoteComms({ relays: testRelays }); // Tell the client to talk to the server a second time @@ -247,7 +249,9 @@ describe.sequential('Remote Communications E2E', () => { // Kill the client and restart it await clientKernel.stop(); - clientKernel = await makeTestKernel(kernelDatabase1, false); + clientKernel = await makeTestKernel(kernelDatabase1, { + resetStorage: false, + }); await clientKernel.initRemoteComms({ relays: testRelays }); // Tell the client to talk to the server a third time @@ -608,7 +612,7 @@ describe.sequential('Remote Communications E2E', () => { try { await kernel1.initRemoteComms({ relays: testRelays }); await kernel2.initRemoteComms({ relays: testRelays }); - kernel3 = await makeTestKernel(kernelDatabase3, true); + kernel3 = await makeTestKernel(kernelDatabase3); await kernel3.initRemoteComms({ relays: testRelays }); const aliceConfig = makeRemoteVatConfig('Alice'); @@ -900,7 +904,7 @@ describe.sequential('Remote Communications E2E', () => { // Create a completely new kernel (new incarnation ID, no previous state) // eslint-disable-next-line require-atomic-updates - kernel2 = await makeTestKernel(kernelDatabase2, true); + kernel2 = await makeTestKernel(kernelDatabase2); await kernel2.initRemoteComms({ relays: testRelays }); // Launch Bob again (fresh vat, no previous state) diff --git a/packages/nodejs/test/e2e/system-subcluster.test.ts b/packages/nodejs/test/e2e/system-subcluster.test.ts index ce81832a6..cca488470 100644 --- a/packages/nodejs/test/e2e/system-subcluster.test.ts +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -51,7 +51,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -63,7 +63,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -77,7 +77,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -92,7 +92,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -109,7 +109,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -134,7 +134,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -156,7 +156,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -199,7 +199,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -252,7 +252,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -270,7 +270,8 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates - kernel = await makeTestKernel(kernelDatabase, false, { + kernel = await makeTestKernel(kernelDatabase, { + resetStorage: false, systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -296,7 +297,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -323,7 +324,8 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates - kernel = await makeTestKernel(kernelDatabase, false, { + kernel = await makeTestKernel(kernelDatabase, { + resetStorage: false, systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -362,7 +364,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -396,7 +398,8 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernel = undefined; // eslint-disable-next-line require-atomic-updates - kernel = await makeTestKernel(kernelDatabase, false, { + kernel = await makeTestKernel(kernelDatabase, { + resetStorage: false, systemSubclusters: [makeSystemSubclusterConfig('test-system')], }); @@ -415,7 +418,7 @@ describe('System Subcluster', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:', }); - kernel = await makeTestKernel(kernelDatabase, true, { + kernel = await makeTestKernel(kernelDatabase, { systemSubclusters: [ makeSystemSubclusterConfig('system-1'), makeSystemSubclusterConfig('system-2'), diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index 869de4234..524b17bb7 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -10,6 +10,7 @@ import type { import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; type MakeTestKernelOptions = { + resetStorage?: boolean; mnemonic?: string; systemSubclusters?: SystemSubclusterConfig[]; }; @@ -19,19 +20,17 @@ type MakeTestKernelOptions = { * This avoids creating the database twice. * * @param kernelDatabase - The kernel database to use. - * @param resetStorage - Whether to reset the storage. - * @param mnemonicOrOptions - Optional BIP39 mnemonic string or options bag. + * @param options - Options for the test kernel. + * @param options.resetStorage - Whether to reset the storage (default: true). + * @param options.mnemonic - Optional BIP39 mnemonic string. + * @param options.systemSubclusters - Optional system subcluster configurations. * @returns The kernel. */ export async function makeTestKernel( kernelDatabase: KernelDatabase, - resetStorage: boolean, - mnemonicOrOptions?: string | MakeTestKernelOptions, + options: MakeTestKernelOptions = {}, ): Promise { - const options: MakeTestKernelOptions = - typeof mnemonicOrOptions === 'string' - ? { mnemonic: mnemonicOrOptions } - : (mnemonicOrOptions ?? {}); + const { resetStorage = true, mnemonic, systemSubclusters } = options; const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ @@ -39,8 +38,8 @@ export async function makeTestKernel( }); const kernel = await Kernel.make(platformServices, kernelDatabase, { resetStorage, - mnemonic: options.mnemonic, - systemSubclusters: options.systemSubclusters, + mnemonic, + systemSubclusters, logger: logger.subLogger({ tags: ['kernel'] }), }); diff --git a/packages/nodejs/test/helpers/remote-comms.ts b/packages/nodejs/test/helpers/remote-comms.ts index 303a31726..553fe02b9 100644 --- a/packages/nodejs/test/helpers/remote-comms.ts +++ b/packages/nodejs/test/helpers/remote-comms.ts @@ -142,7 +142,7 @@ export async function restartKernel( resetStorage: boolean, relays: string[], ): Promise { - const kernel = await makeTestKernel(kernelDatabase, resetStorage); + const kernel = await makeTestKernel(kernelDatabase, { resetStorage }); await kernel.initRemoteComms({ relays }); return kernel; } From 7935dd7b95f84a1478711234f04506325256a402 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:02:42 -0800 Subject: [PATCH 41/47] chore: Extend vitest eslint rules to all test files Apply vitest eslint config to `**/test/**/*` in addition to `**/*.test.ts` files, so non-test-named files under test directories also get the right rules. Remove now-unnecessary eslint-disable comments in system-vat.ts. Co-Authored-By: Claude Opus 4.6 --- eslint.config.mjs | 2 +- packages/nodejs/test/vats/system-vat.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c956602d..5daea9601 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -138,7 +138,7 @@ const config = createConfig([ }, { - files: ['**/*.test.ts', '**/*.test.tsx'], + files: ['**/test/**/*', '**/*.test.ts', '**/*.test.tsx'], extends: [metamaskVitestConfig], rules: { // It's fine to do this in tests. diff --git a/packages/nodejs/test/vats/system-vat.ts b/packages/nodejs/test/vats/system-vat.ts index cbd4cea7f..7c5ece156 100644 --- a/packages/nodejs/test/vats/system-vat.ts +++ b/packages/nodejs/test/vats/system-vat.ts @@ -86,7 +86,6 @@ export function buildRootObject( * @returns The kernel status. */ async getKernelStatus(): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return E(kernelFacet!).getStatus(); }, @@ -96,7 +95,6 @@ export function buildRootObject( * @returns The list of subclusters. */ async getSubclusters(): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return E(kernelFacet!).getSubclusters(); }, @@ -109,7 +107,6 @@ export function buildRootObject( async launchSubcluster( config: ClusterConfig, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return E(kernelFacet!).launchSubcluster(config); }, @@ -120,7 +117,6 @@ export function buildRootObject( * @returns A promise that resolves when the subcluster is terminated. */ async terminateSubcluster(subclusterId: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return E(kernelFacet!).terminateSubcluster(subclusterId); }, From 6f7cff41291b8516e3f1b139d1fb46002a8c315c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:08:27 -0800 Subject: [PATCH 42/47] fix(omnium): Fix omnium.caplet global type declarations The omnium.caplet type was declared as Promisified but the implementation routes through queueMessage, returning raw CapData instead of deserialized values. Replace with explicit method signatures using QueueMessageResult, and add the missing callCapletMethod and getCapletRoot methods. Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/kernel-facet.ts | 2 +- packages/omnium-gatherum/src/background.ts | 4 ++- packages/omnium-gatherum/src/global.d.ts | 33 +++++++++++----------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index eda82db70..b4d935d88 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -56,5 +56,5 @@ export function makeKernelFacet(kernel: KernelFacetSource): KernelFacet { return makeDefaultExo('kernelFacet', { ...bound, ping: () => 'pong' as const, - }) as KernelFacet; + }) as unknown as KernelFacet; } diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 26109af4d..7e3bfbfb8 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -16,6 +16,8 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import type { CapletManifest } from './controllers/index.ts'; +export type QueueMessageResult = ReturnType; + const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); const globals = defineGlobals(); @@ -163,7 +165,7 @@ function defineGlobals(): GlobalSetters { const callController = async ( method: string, args: unknown[] = [], - ): ReturnType => { + ): QueueMessageResult => { if (!kernel) { throw new Error('Kernel not initialized'); } diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 32af9ede9..04af45065 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,10 +1,7 @@ -import type { Promisified } from '@metamask/kernel-utils'; import type { KernelFacet } from '@metamask/ocap-kernel'; -import type { - CapletControllerFacet, - CapletManifest, -} from './controllers/index.ts'; +import type { QueueMessageResult } from './background.ts'; +import type { CapletManifest } from './controllers/index.ts'; // Type declarations for omnium dev console API. declare global { @@ -28,19 +25,21 @@ declare global { var omnium: { /** * Caplet management API. + * + * Methods that delegate to the controller vat via queueMessage return + * raw CapData. Use `kunser()` to deserialize the results. */ - caplet: Promisified & { - /** - * Load a caplet's manifest and bundle by ID. - * - * @param id - The short caplet ID (e.g., 'echo'). - * @returns The manifest and bundle for installation. - * @example - * ```typescript - * const { manifest, bundle } = await omnium.caplet.load('echo'); - * await omnium.caplet.install(manifest); - * ``` - */ + caplet: { + install: (manifest: CapletManifest) => QueueMessageResult; + uninstall: (capletId: string) => QueueMessageResult; + list: () => QueueMessageResult; + get: (capletId: string) => QueueMessageResult; + getCapletRoot: (capletId: string) => Promise; + callCapletMethod: ( + capletId: string, + method: string, + args: unknown[], + ) => QueueMessageResult; load: ( id: string, ) => Promise<{ manifest: CapletManifest; bundle: unknown }>; From 8691db8b2015f2791b7c85ce2e30c0dc8294477c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:35:59 -0800 Subject: [PATCH 43/47] test(nodejs): Clean up system sybcluster e2e tests --- .../nodejs/test/e2e/system-subcluster.test.ts | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/packages/nodejs/test/e2e/system-subcluster.test.ts b/packages/nodejs/test/e2e/system-subcluster.test.ts index cca488470..ea42fb08a 100644 --- a/packages/nodejs/test/e2e/system-subcluster.test.ts +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -72,19 +72,6 @@ describe('System Subcluster', { timeout: 30_000 }, () => { expect(typeof root).toBe('string'); expect(root).toMatch(/^ko\d+$/u); }); - - it('throws for unknown system subcluster name', async () => { - kernelDatabase = await makeSQLKernelDatabase({ - dbFilename: ':memory:', - }); - kernel = await makeTestKernel(kernelDatabase, { - systemSubclusters: [makeSystemSubclusterConfig('test-system')], - }); - - expect(() => kernel!.getSystemSubclusterRoot('unknown-cluster')).toThrow( - 'System subcluster "unknown-cluster" not found', - ); - }); }); describe('kernel services', () => { @@ -124,31 +111,9 @@ describe('System Subcluster', { timeout: 30_000 }, () => { subclusters: unknown[]; }; expect(status).toBeDefined(); - expect(Array.isArray(status.vats)).toBe(true); expect(status.vats).toHaveLength(1); - expect(Array.isArray(status.subclusters)).toBe(true); expect(status.subclusters).toHaveLength(1); }); - - it('retrieves subclusters via kernelFacet', async () => { - kernelDatabase = await makeSQLKernelDatabase({ - dbFilename: ':memory:', - }); - kernel = await makeTestKernel(kernelDatabase, { - systemSubclusters: [makeSystemSubclusterConfig('test-system')], - }); - - const root = kernel.getSystemSubclusterRoot('test-system'); - expect(root).toBeDefined(); - - const result = await kernel.queueMessage(root, 'getSubclusters', []); - await delay(); - - const subclusters = kunser(result) as unknown[]; - expect(Array.isArray(subclusters)).toBe(true); - // At least the system subcluster should exist - expect(subclusters).toHaveLength(1); - }); }); describe('subcluster management', () => { @@ -265,8 +230,6 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Stop kernel but keep database await kernel.stop(); - // eslint-disable-next-line require-atomic-updates - kernel = undefined; // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates @@ -319,8 +282,6 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Stop kernel but keep database await kernel.stop(); - // eslint-disable-next-line require-atomic-updates - kernel = undefined; // Restart kernel with same system subcluster config (resetStorage = false) // eslint-disable-next-line require-atomic-updates @@ -394,8 +355,6 @@ describe('System Subcluster', { timeout: 30_000 }, () => { // Stop and restart kernel — should not throw await kernel.stop(); - // eslint-disable-next-line require-atomic-updates - kernel = undefined; // eslint-disable-next-line require-atomic-updates kernel = await makeTestKernel(kernelDatabase, { From 87604ff688fe9a13cd95e3ea1746bb2ba5dd5386 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:52:03 -0800 Subject: [PATCH 44/47] fix(ocap-kernel): Use hasProperty to check allowed globals --- packages/ocap-kernel/src/vats/VatSupervisor.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index e935e5490..c8ddb25ce 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -20,7 +20,11 @@ import type { JsonRpcMessage } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; import { serializeError } from '@metamask/rpc-errors'; import type { DuplexStream } from '@metamask/streams'; -import { isJsonRpcRequest, isJsonRpcResponse } from '@metamask/utils'; +import { + hasProperty, + isJsonRpcRequest, + isJsonRpcResponse, +} from '@metamask/utils'; import type { PlatformFactory } from '@ocap/kernel-platforms'; import { loadBundle } from './bundle-loader.ts'; @@ -130,7 +134,7 @@ export class VatSupervisor { this.#vatPowers = vatPowers ?? {}; this.#dispatch = null; const defaultFetchBlob: FetchBlob = async (bundleURL: string) => - await fetch(bundleURL, { cache: 'no-store' }); + await fetch(bundleURL, { cache: 'no-store' } as RequestInit); this.#fetchBlob = fetchBlob ?? defaultFetchBlob; this.#platformOptions = platformOptions ?? {}; this.#makePlatform = makePlatform; @@ -304,7 +308,7 @@ export class VatSupervisor { const requestedGlobals: Record = {}; if (globals) { for (const name of globals) { - if (name in allowedGlobals) { + if (hasProperty(allowedGlobals, name)) { requestedGlobals[name] = allowedGlobals[name]; } } From cc6ceec87ec996c94de01bcb3653a22b9915fffd Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:25:19 -0800 Subject: [PATCH 45/47] test(omnium): Add non-trivial e2e test --- .../test/e2e/echo-caplet.test.ts | 59 +++++++++++++++++++ .../omnium-gatherum/test/e2e/smoke.test.ts | 27 --------- 2 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 packages/omnium-gatherum/test/e2e/echo-caplet.test.ts delete mode 100644 packages/omnium-gatherum/test/e2e/smoke.test.ts diff --git a/packages/omnium-gatherum/test/e2e/echo-caplet.test.ts b/packages/omnium-gatherum/test/e2e/echo-caplet.test.ts new file mode 100644 index 000000000..02960807b --- /dev/null +++ b/packages/omnium-gatherum/test/e2e/echo-caplet.test.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; +import type { + BrowserContext, + Worker as PlaywrightWorker, +} from '@playwright/test'; + +import { loadExtension } from './utils.ts'; + +test.describe('Echo caplet', () => { + let browserContext: BrowserContext; + let serviceWorker: PlaywrightWorker; + + test.beforeEach(async () => { + const extension = await loadExtension(); + browserContext = extension.browserContext; + + const workers = browserContext.serviceWorkers(); + const sw = workers[0]; + if (!sw) { + throw new Error('No service worker found'); + } + serviceWorker = sw; + }); + + test.afterEach(async () => { + await browserContext.close(); + }); + + test('loads, installs, and calls the echo caplet', async () => { + // Wait for the controller vat to initialize by polling omnium.caplet.list() + await expect(async () => { + await serviceWorker.evaluate(async () => globalThis.omnium.caplet.list()); + }).toPass({ timeout: 30_000 }); + + // Load the echo caplet manifest and bundle + const { manifest } = await serviceWorker.evaluate(async () => + globalThis.omnium.caplet.load('echo'), + ); + + // Install the echo caplet + await serviceWorker.evaluate( + async (capletManifest) => + globalThis.omnium.caplet.install(capletManifest), + manifest, + ); + + // Call the echo caplet method + const result = await serviceWorker.evaluate(async () => + globalThis.omnium.caplet.callCapletMethod('echo', 'echo', [ + 'Hello, world!', + ]), + ); + + expect(result).toStrictEqual({ + body: '#"echo: Hello, world!"', + slots: [], + }); + }); +}); diff --git a/packages/omnium-gatherum/test/e2e/smoke.test.ts b/packages/omnium-gatherum/test/e2e/smoke.test.ts deleted file mode 100644 index f2ec0f92d..000000000 --- a/packages/omnium-gatherum/test/e2e/smoke.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { test, expect } from '@playwright/test'; -import type { Page, BrowserContext } from '@playwright/test'; - -import { loadExtension } from './utils.ts'; - -test.describe.configure({ mode: 'serial' }); - -test.describe('Smoke Test', () => { - let extensionContext: BrowserContext; - let popupPage: Page; - - test.beforeEach(async () => { - const extension = await loadExtension(); - extensionContext = extension.browserContext; - popupPage = extension.popupPage; - }); - - test.afterEach(async () => { - await extensionContext.close(); - }); - - test('should load popup with kernel expected elements', async () => { - await expect( - popupPage.locator('div:text("Omnium Gatherum")'), - ).toBeVisible(); - }); -}); From 944137936583a7964ef60ce5b9b34d6662bed2fc Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:05:28 -0800 Subject: [PATCH 46/47] fix(omnium): Reject capletFacetP on resuscitation failure --- packages/omnium-gatherum/src/vats/controller-vat.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts index 33fc05d57..d45b1d38c 100644 --- a/packages/omnium-gatherum/src/vats/controller-vat.ts +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -128,6 +128,7 @@ export function buildRootObject( reject: rejectCapletFacet, }).catch((error) => { logger.error('Failed to restore controller from baggage:', error); + rejectCapletFacet(error); }); } From 31868d838c868c989c338c9af6854f47ff1ff873 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:45:58 -0800 Subject: [PATCH 47/47] fix(ocap-kernel): Call provideFacet() unconditionally in Kernel#init On restart with an empty systemSubclusters array, the kernel facet was never registered because provideFacet() was guarded by configs.length > 0. Persisted run queue items targeting the kernel facet kref would cause invokeKernelService to throw, crashing the kernel queue. Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/Kernel.test.ts | 2 +- packages/ocap-kernel/src/Kernel.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index a7469e80b..83c4eeccc 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -1030,7 +1030,7 @@ describe('Kernel', () => { const config = makeSingleVatClusterConfig(); await kernel.launchSubcluster(config); // Pinning existing vat root should return the kref - expect(kernel.pinVatRoot('v1')).toBe('ko3'); + expect(kernel.pinVatRoot('v1')).toBe('ko4'); // Pinning non-existent vat should throw expect(() => kernel.pinVatRoot('v2')).toThrow(VatNotFoundError); // Unpinning existing vat root should succeed diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 615331ecc..fb4e1fa85 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -227,11 +227,16 @@ export class Kernel { this.#remoteManager.handleRemoteMessage(from, message), ); + // Always provide the kernel facet, even when there are no system subcluster + // configs. The run queue may contain messages targeting the kernel facet kref + // from a previous incarnation's system subclusters. If the facet is not + // registered, invokeKernelService throws and crashes the kernel queue. + // Ideally, orphaned messages would be purged before the queue starts, but + // the run queue has no selective removal capability. + this.provideFacet(); + // Restore persisted system subclusters and delete ones that no // longer have a config, to ensure that orphaned vats aren't started - if (configs.length > 0) { - this.provideFacet(); - } this.#subclusterManager.initSystemSubclusters(configs); // Start all vats that were previously running before starting the queue