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/cli/src/vite/vat-bundler.ts b/packages/cli/src/vite/vat-bundler.ts index 2de51c4d7..e35cc174a 100644 --- a/packages/cli/src/vite/vat-bundler.ts +++ b/packages/cli/src/vite/vat-bundler.ts @@ -20,6 +20,12 @@ export async function bundleVat(sourcePath: string): Promise { const result = await build({ configFile: false, logLevel: 'silent', + // 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'), + }, build: { write: false, lib: { 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/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"]'); 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..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 @@ -1,5 +1,6 @@ import { E } from '@endo/eventual-send'; import type { ClusterConfig, Kernel } 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'; @@ -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,59 @@ 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'], + 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. + // 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( + 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 // 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 +93,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 +144,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..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,11 +1,33 @@ import type { Kernel } 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'; import type { CapTPMessage } from '../../types.ts'; describe('makeKernelCapTP', () => { - const mockKernel: Kernel = {} as unknown as Kernel; + const mockKernel = { + getPresence: vi + .fn() + .mockImplementation(async (kref: string, iface: string) => + kslot(kref, iface), + ), + 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 51d3cc9a4..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ /dev/null @@ -1,47 +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 }; - }, - }); -} -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..0c8255a45 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,16 @@ 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 systemSubclustersParam = urlParams.get('system-subclusters'); + const systemSubclusters = systemSubclustersParam + ? JSON.parse(systemSubclustersParam) + : undefined; const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { resetStorage, + systemSubclusters, }); const handlerP = kernelP.then((kernel) => { 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 02d014d2b..a9d46c374 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -1,32 +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; -}; 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/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 772905244..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 @@ -524,14 +528,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']); @@ -606,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'); @@ -898,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 new file mode 100644 index 000000000..ea42fb08a --- /dev/null +++ b/packages/nodejs/test/e2e/system-subcluster.test.ts @@ -0,0 +1,407 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { Kernel, kunser } 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'; + +const SYSTEM_VAT_BUNDLE_URL = 'http://localhost:3000/system-vat.bundle'; +const SAMPLE_VAT_BUNDLE_URL = 'http://localhost:3000/sample-vat.bundle'; + +describe('System Subcluster', { timeout: 30_000 }, () => { + let kernel: Kernel | undefined; + let kernelDatabase: KernelDatabase | undefined; + + const makeSystemSubclusterConfig = ( + name: string, + services: string[] = ['kernelFacet'], + ): SystemSubclusterConfig => ({ + name, + config: { + bootstrap: name, + vats: { + [name]: { + bundleSpec: SYSTEM_VAT_BUNDLE_URL, + parameters: { name }, + }, + }, + ...(services.length > 0 && { services }), + }, + }); + + afterEach(async () => { + if (kernel) { + const stopResult = kernel.stop(); + kernel = undefined; + await stopResult; + } + if (kernelDatabase) { + kernelDatabase.close(); + kernelDatabase = undefined; + } + }); + + describe('initialization', () => { + it('launches system subcluster at kernel initialization', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + // System subcluster's bootstrap vat should be running + expect(kernel.getVatIds().length).toBeGreaterThan(0); + }); + + it('provides bootstrap root via getSystemSubclusterRoot', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + const root = kernel.getSystemSubclusterRoot('test-system'); + expect(root).toBeDefined(); + expect(typeof root).toBe('string'); + expect(root).toMatch(/^ko\d+$/u); + }); + }); + + describe('kernel services', () => { + it('receives kernelFacet service in bootstrap', 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, 'hasKernelFacet', []); + await delay(); + + expect(kunser(result)).toBe(true); + }); + + it('queries kernel status 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, 'getKernelStatus', []); + await delay(); + + const status = kunser(result) as { + vats: unknown[]; + subclusters: unknown[]; + }; + expect(status).toBeDefined(); + expect(status.vats).toHaveLength(1); + expect(status.subclusters).toHaveLength(1); + }); + }); + + describe('subcluster management', () => { + it('launches subcluster 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(); + + // Get initial subcluster count + const initialResult = await kernel.queueMessage( + root, + 'getSubclusters', + [], + ); + await delay(); + const initialSubclusters = kunser(initialResult) as unknown[]; + expect(initialSubclusters).toHaveLength(1); + + // Launch a new subcluster via the system subcluster's bootstrap vat + const config: ClusterConfig = { + bootstrap: 'child', + vats: { + child: { + bundleSpec: SAMPLE_VAT_BUNDLE_URL, + parameters: { name: 'child-vat' }, + }, + }, + }; + + await kernel.queueMessage(root, 'launchSubcluster', [config]); + await delay(); + + // Verify subcluster was created + const afterResult = await kernel.queueMessage(root, 'getSubclusters', []); + await delay(); + 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, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + const root = kernel.getSystemSubclusterRoot('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 delay(); + const launchData = kunser(launchResult) as { subclusterId: string }; + const { subclusterId } = launchData; + + // Get count before termination + const beforeResult = await kernel.queueMessage( + root, + 'getSubclusters', + [], + ); + await delay(); + const beforeSubclusters = kunser(beforeResult) as unknown[]; + expect(beforeSubclusters).toHaveLength(2); + + // Terminate the subcluster + await kernel.queueMessage(root, 'terminateSubcluster', [subclusterId]); + await delay(); + + // Verify subcluster was terminated + const afterResult = await kernel.queueMessage(root, 'getSubclusters', []); + await delay(); + const afterSubclusters = kunser(afterResult) as unknown[]; + + expect(afterSubclusters).toHaveLength(1); + }); + }); + + describe('system subcluster persistence', () => { + it('restores existing system subcluster on kernel restart', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + // Get initial subcluster info + const initialSubclusters = kernel.getSubclusters(); + expect(initialSubclusters).toHaveLength(1); + const initialSubclusterId = initialSubclusters[0]!.id; + const initialRoot = kernel.getSystemSubclusterRoot('test-system'); + expect(initialRoot).toBeDefined(); + + // Stop kernel but keep database + await kernel.stop(); + + // Restart kernel with same system subcluster config (resetStorage = false) + // eslint-disable-next-line require-atomic-updates + kernel = await makeTestKernel(kernelDatabase, { + resetStorage: false, + systemSubclusters: [makeSystemSubclusterConfig('test-system')], + }); + + // System subcluster should be restored (not relaunched) + const newSubclusters = kernel.getSubclusters(); + expect(newSubclusters).toHaveLength(1); + const newSubclusterId = newSubclusters[0]!.id; + + // Subcluster ID should be the SAME (restored from persistence) + expect(newSubclusterId).toBe(initialSubclusterId); + + // Bootstrap root should be restored + const newRoot = kernel.getSystemSubclusterRoot('test-system'); + expect(newRoot).toBeDefined(); + expect(newRoot).toBe(initialRoot); + + const result = await kernel.queueMessage(newRoot, 'hasKernelFacet', []); + await delay(); + expect(kunser(result)).toBe(true); + }); + + it('persists baggage data across kernel restarts', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + systemSubclusters: [makeSystemSubclusterConfig('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 delay(); + + // Verify data was stored + const storedResult = await kernel.queueMessage(root, 'getFromBaggage', [ + testKey, + ]); + await delay(); + expect(kunser(storedResult)).toBe(testValue); + + // Stop kernel but keep database + await kernel.stop(); + + // Restart kernel with same system subcluster config (resetStorage = false) + // eslint-disable-next-line require-atomic-updates + kernel = await makeTestKernel(kernelDatabase, { + resetStorage: false, + systemSubclusters: [makeSystemSubclusterConfig('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( + newRoot, + 'getFromBaggage', + [testKey], + ); + await delay(); + expect(kunser(persistedResult)).toBe(testValue); + + // Verify key exists check works + 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', [ + 'non-existent-key', + ]); + await delay(); + expect(kunser(noKeyResult)).toBe(false); + }); + }); + + describe('system subcluster reload', () => { + it('updates system subcluster mapping after reload and survives restart', async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + 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 = await makeTestKernel(kernelDatabase, { + resetStorage: 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({ + dbFilename: ':memory:', + }); + kernel = await makeTestKernel(kernelDatabase, { + systemSubclusters: [ + makeSystemSubclusterConfig('system-1'), + makeSystemSubclusterConfig('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 delay(); + expect(kunser(result1)).toBe(true); + + const result2 = await kernel.queueMessage(root2, 'hasKernelFacet', []); + await delay(); + 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..524b17bb7 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -2,24 +2,36 @@ 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, + SystemSubclusterConfig, +} from '@metamask/ocap-kernel'; import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; +type MakeTestKernelOptions = { + resetStorage?: boolean; + mnemonic?: string; + systemSubclusters?: SystemSubclusterConfig[]; +}; + /** * 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 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, - mnemonic?: string, + options: MakeTestKernelOptions = {}, ): Promise { + const { resetStorage = true, mnemonic, systemSubclusters } = options; + const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ logger: logger.subLogger({ tags: ['platform-services'] }), @@ -27,6 +39,7 @@ export async function makeTestKernel( const kernel = await Kernel.make(platformServices, kernelDatabase, { resetStorage, 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; } diff --git a/packages/nodejs/test/vats/system-vat.ts b/packages/nodejs/test/vats/system-vat.ts new file mode 100644 index 000000000..7c5ece156 --- /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); + }, + }); +} diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index dc6bdadc1..83c4eeccc 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), }); }); }); @@ -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); }); }); @@ -820,6 +832,39 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toHaveLength(0); }); + it('clears system subcluster roots', async () => { + const mockDb = makeMapKernelDatabase(); + const kernel = await Kernel.make(mockPlatformServices, mockDb, { + systemSubclusters: [ + { + name: 'testSystemSubcluster', + config: { + bootstrap: 'testSystemSubcluster', + vats: { + testSystemSubcluster: { + sourceSpec: 'system-vat.js', + }, + }, + }, + }, + ], + }); + // 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 subcluster roots are cleared + expect(() => + kernel.getSystemSubclusterRoot('testSystemSubcluster'), + ).toThrow('System subcluster "testSystemSubcluster" not found'); + }); + it('logs an error if resetting the kernel state fails', async () => { const mockDb = makeMapKernelDatabase(); const logger = new Logger('test'); @@ -840,6 +885,120 @@ 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'), + ).toThrow('System subcluster "testSystemSubcluster" not found'); + }); + + it('throws if persisted system subcluster has no bootstrap vat', 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 bootstrap vat - 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', () => { it('reflect when an object is revoked', async () => { const kernel = await Kernel.make( @@ -871,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 c9157c8c4..fb4e1fa85 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -2,10 +2,14 @@ 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 type { KernelFacet } from './kernel-facet.ts'; 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'; @@ -23,8 +27,9 @@ import type { Subcluster, SubclusterLaunchResult, 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'; @@ -149,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( @@ -186,6 +192,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.systemSubclusters - Optional array of system subcluster configurations. * @returns A promise for the new kernel instance. */ static async make( @@ -196,23 +203,42 @@ export class Kernel { logger?: Logger; keySeed?: string | undefined; mnemonic?: string | undefined; + systemSubclusters?: SystemSubclusterConfig[]; } = {}, ): Promise { const kernel = new Kernel(platformServices, kernelDatabase, options); - await kernel.#init(); + await kernel.#init(options.systemSubclusters); return kernel; } /** * Start the kernel running. + * + * @param systemSubclusterConfigs - Optional array of system subcluster configurations. */ - async #init(): Promise { + 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), ); + // 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 + 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 await this.#vatManager.initializeAllVats(); @@ -228,6 +254,29 @@ export class Kernel { ); // Don't re-throw to avoid unhandled rejection in this long-running task }); + + // Launch new system subclusters (requires queue to be running) + await this.#subclusterManager.launchNewSystemSubclusters(configs); + } + + /** + * Provide the kernel facet, creating and registering it as a kernel service + * if it doesn't already exist. + * + * @returns The kernel facet. + */ + provideFacet(): KernelFacet { + const existing = this.#kernelServiceManager.getKernelService('kernelFacet'); + if (existing) { + return existing.service as KernelFacet; + } + + const kernelFacet = makeKernelFacet(this); + this.#kernelServiceManager.registerKernelServiceObject( + 'kernelFacet', + kernelFacet, + ); + return kernelFacet; } /** @@ -328,8 +377,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); } @@ -342,6 +395,30 @@ export class Kernel { return this.#subclusterManager.getSubclusters(); } + /** + * 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 this.#subclusterManager.getSystemSubclusterRoot(name); + } + + /** + * 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. * @@ -538,6 +615,7 @@ export class Kernel { await this.#kernelQueue.waitForCrank(); try { await this.terminateAllVats(); + this.#subclusterManager.clearSystemSubclusters(); this.#resetKernelState(); } catch (error) { this.#logger.error('Error resetting kernel:', error); 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 0334437b2..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'; @@ -94,6 +95,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', () => { @@ -173,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(); @@ -195,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', [ @@ -220,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)], @@ -244,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:', @@ -253,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 () => { @@ -278,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'"))], @@ -300,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'", @@ -321,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'"))], @@ -343,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 5fcb74fb8..4741f6255 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) { @@ -109,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}`); @@ -138,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); } } } 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 ee88fd403..3d448fd26 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -15,6 +15,7 @@ export type { Subcluster, SubclusterId, SubclusterLaunchResult, + SystemSubclusterConfig, } from './types.ts'; export type { RemoteMessageHandler, @@ -35,6 +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 } 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 new file mode 100644 index 000000000..ceb1df646 --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; + +import type { KernelFacetSource } from './kernel-facet.ts'; +import { makeKernelFacet } from './kernel-facet.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; + +const makeMockKernel = (): KernelFacetSource => ({ + 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', () => { + it('creates an exo with all dependency methods and ping', () => { + const facet = makeKernelFacet(makeMockKernel()); + + 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'); + }); + + it('ping returns "pong"', () => { + const facet = makeKernelFacet(makeMockKernel()); + expect(facet.ping()).toBe('pong'); + }); + + it('delegates dependency methods to the provided functions', async () => { + const facet = makeKernelFacet(makeMockKernel()); + + expect(facet.getSystemSubclusterRoot('test')).toBe('ko99'); + expect(await facet.getStatus()).toStrictEqual({ + vats: [], + subclusters: [], + }); + 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 new file mode 100644 index 000000000..b4d935d88 --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -0,0 +1,60 @@ +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; + +/** + * The subset of Kernel that the kernel facet exposes. + */ +export type KernelFacetSource = Pick< + Kernel, + (typeof kernelFacetMethodNames)[number] +>; + +/** + * 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. + */ +export type KernelFacet = KernelFacetSource & { + /** + * Ping the kernel. + * + * @returns The string 'pong'. + */ + ping: () => 'pong'; +}; + +/** + * Creates a kernel facet exo that provides privileged kernel operations. + * + * Binds each delegated method to the kernel instance so that private field + * access works correctly when the methods are called through the exo. + * + * @param kernel - The kernel instance to bind methods from. + * @returns The kernel facet exo. + */ +export function makeKernelFacet(kernel: KernelFacetSource): KernelFacet { + const bound: Record = {}; + for (const name of kernelFacetMethodNames) { + bound[name] = kernel[name].bind(kernel); + } + return makeDefaultExo('kernelFacet', { + ...bound, + ping: () => 'pong' as const, + }) as unknown as KernelFacet; +} 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/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 8b1260a7a..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); @@ -239,6 +246,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 +310,9 @@ export function getSubclusterMethods(ctx: StoreContext) { getVatSubcluster, clearEmptySubclusters, removeVatFromSubcluster, + getSystemSubclusterMapping, + setSystemSubclusterMapping, + deleteSystemSubclusterMapping, + getAllSystemSubclusterMappings, }; } 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 550232c00..ad4033ff2 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()))) ); }); @@ -428,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; @@ -440,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; }; @@ -538,3 +540,16 @@ export type EndpointHandle = { deliverRetireImports: (erefs: ERef[]) => Promise; deliverBringOutYourDead: () => Promise; }; + +/** + * 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 SystemSubclusterConfig = { + /** Unique name for this system subcluster (used for retrieval via `getSystemSubclusterRoot`) */ + name: string; + /** The cluster configuration */ + config: ClusterConfig; +}; diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 427c825e4..123c91539 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'; @@ -36,7 +42,10 @@ describe('SubclusterManager', () => { ): Subcluster => ({ id, config, - vats: ['v1', 'v2'] as VatId[], + vats: { [`${config.bootstrap}`]: 'v1', vat2: 'v2' } as Record< + string, + VatId + >, }); beforeEach(() => { @@ -47,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 = { @@ -95,6 +110,7 @@ describe('SubclusterManager', () => { expect(mockKernelStore.addSubcluster).toHaveBeenCalledWith(config); expect(mockVatManager.launchVat).toHaveBeenCalledWith( config.vats.testVat, + 'testVat', 's1', ); expect(mockQueueMessage).toHaveBeenCalledWith('ko1', 'bootstrap', [ @@ -103,7 +119,7 @@ describe('SubclusterManager', () => { ]); expect(result).toStrictEqual({ subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult: { body: '{"result":"ok"}', slots: [] }, }); }); @@ -125,10 +141,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', ); }); @@ -223,7 +241,7 @@ describe('SubclusterManager', () => { const result = await subclusterManager.launchSubcluster(config); expect(result).toStrictEqual({ subclusterId: 's1', - bootstrapRootKref: 'ko1', + rootKref: 'ko1', bootstrapResult, }); }); @@ -456,5 +474,366 @@ 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', () => { + 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 663751d2c..e443657cb 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); } @@ -92,9 +104,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 }; } /** @@ -108,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); @@ -130,7 +156,20 @@ export class SubclusterManager { if (!subcluster) { throw new SubclusterNotFoundError(subclusterId); } - for (const vatId of subcluster.vats.reverse()) { + + // 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(); } @@ -140,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; } @@ -183,6 +247,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 Object.values(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. * @@ -194,13 +280,17 @@ export class SubclusterManager { subclusterId: string, config: ClusterConfig, ): Promise<{ - bootstrapRootKref: KRef; + rootKref: KRef; bootstrapResult: CapData | undefined; }> { 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'); } @@ -216,22 +306,155 @@ 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 }; + } + + /** + * 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; } /** @@ -240,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}`, + ); + } } } } 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; } diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index 48058a066..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; @@ -293,13 +297,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 (hasProperty(allowedGlobals, name)) { + 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/README.md b/packages/omnium-gatherum/README.md index 688955bae..6e4073c3b 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -10,6 +10,24 @@ 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. Call a method on the caplet +await omnium.caplet.callCapletMethod('echo', 'echo', ['Hello, world!']); +// echo: Hello, world! +``` + ## 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..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", + "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", @@ -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..7e3bfbfb8 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -4,18 +4,19 @@ import { makeCapTPNotification, isCapTPNotification, getCapTPMessage, + isConsoleForwardMessage, + handleConsoleForwardMessage, } 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 { initializeControllers } from './controllers/index.ts'; -import type { - CapletControllerFacet, - CapletManifest, -} from './controllers/index.ts'; +import type { CapletManifest } from './controllers/index.ts'; + +export type QueueMessageResult = ReturnType; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -106,21 +107,25 @@ async function main(): Promise { }); const kernelP = backgroundCapTP.getKernel(); - globalThis.kernel = kernelP; + globals.setKernel(kernelP); - try { - const controllers = await initializeControllers({ - logger, - kernel: kernelP, + // Set up controller vat initialization (runs concurrently with stream drain) + E(kernelP) + .getSystemSubclusterRoot('omnium-controllers') + .then((kref) => { + globals.setControllerVatKref(kref); + logger.info('Controller vat initialized'); + return undefined; + }) + .catch((error) => { + logger.error('Failed to initialize controller vat:', error); }); - globals.setCapletController(controllers.caplet); - } catch (error) { - offscreenStream.throw(error as Error).catch(logger.error); - } 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 { @@ -137,7 +142,9 @@ async function main(): Promise { } type GlobalSetters = { - setCapletController: (value: CapletControllerFacet) => void; + setKernel: (kernel: KernelFacet | Promise) => void; + // Not globally available, but needed for other globals to work + setControllerVatKref: (kref: string) => void; }; /** @@ -146,7 +153,28 @@ type GlobalSetters = { * @returns A device for setting the global values. */ function defineGlobals(): GlobalSetters { - let capletController: CapletControllerFacet; + let controllerVatKref: string; + + /** + * 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 controller vat. + */ + const callController = async ( + method: string, + args: unknown[] = [], + ): QueueMessageResult => { + if (!kernel) { + throw new Error('Kernel not initialized'); + } + if (!controllerVatKref) { + throw new Error('Controller vat not initialized'); + } + + return await E(kernel).queueMessage(controllerVatKref, method, args); + }; Object.defineProperty(globalThis, 'E', { configurable: false, @@ -206,26 +234,45 @@ function defineGlobals(): GlobalSetters { return { manifest, bundle }; }; + const getCapletRoot = async (capletId: string): Promise => { + const { slots } = await callController('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) => - E(capletController).install(manifest), + callController('install', [manifest]), uninstall: async (capletId: string) => - E(capletController).uninstall(capletId), - list: async () => E(capletController).list(), + callController('uninstall', [capletId]), + list: async () => callController('list'), load: loadCaplet, - get: async (capletId: string) => E(capletController).get(capletId), - getCapletRoot: async (capletId: string) => - E(capletController).getCapletRoot(capletId), + get: async (capletId: string) => callController('get', [capletId]), + getCapletRoot, + callCapletMethod: async ( + capletId: string, + method: string, + args: unknown[], + ) => { + const rootKref = await getCapletRoot(capletId); + return await E(kernel).queueMessage(rootKref, method, args); + }, }), }, }); harden(globalThis.omnium); return { - setCapletController: (value) => { - capletController = value; + setKernel: (kernel) => { + globalThis.kernel = kernel; + }, + setControllerVatKref: (kref) => { + controllerVatKref = kref; }, }; } 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/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 5a1f929d0..90b1c1076 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, - launchSubcluster: (config: ClusterConfig) => Promise, + launchSubcluster: ( + config: ClusterConfig, + ) => Promise, terminateSubcluster: (subclusterId: string) => Promise, getVatRoot: (krefString: string) => Promise, ) { @@ -307,7 +313,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/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 e31664d41..b62bae95d 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 { @@ -28,7 +16,6 @@ export type { CapletManifest, InstalledCaplet, InstallResult, - LaunchResult, CapletControllerState, CapletControllerFacet, CapletControllerDeps, @@ -43,58 +30,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'; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 545ed5d14..04af45065 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,10 +1,7 @@ -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, - 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 { @@ -22,25 +19,27 @@ 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: { /** * 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 }>; diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 0cf807894..a3540d11a 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -2,10 +2,13 @@ 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'; import { Logger } from '@metamask/logger'; +import type { SystemSubclusterConfig } from '@metamask/ocap-kernel'; import type { DuplexStream } from '@metamask/streams'; import { initializeMessageChannel, @@ -31,6 +34,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 @@ -56,6 +73,25 @@ async function makeKernelWorker(): Promise< const workerUrlParams = new URLSearchParams(relayQueryString); workerUrlParams.set('reset-storage', process.env.RESET_STORAGE ?? 'false'); + // Configure system subclusters to launch at kernel initialization + const systemSubclusters = [ + { + name: 'omnium-controllers', + config: { + bootstrap: 'omnium-controllers', + vats: { + 'omnium-controllers': { + bundleSpec: chrome.runtime.getURL('controller-vat-bundle.json'), + parameters: {}, + globals: ['Date'], + }, + }, + services: ['kernelFacet'], + }, + }, + ] satisfies SystemSubclusterConfig[]; + workerUrlParams.set('system-subclusters', JSON.stringify(systemSubclusters)); + const workerUrl = new URL('kernel-worker.js', import.meta.url); workerUrl.search = workerUrlParams.toString(); diff --git a/packages/omnium-gatherum/src/vats/controller-vat.ts b/packages/omnium-gatherum/src/vats/controller-vat.ts new file mode 100644 index 000000000..d45b1d38c --- /dev/null +++ b/packages/omnium-gatherum/src/vats/controller-vat.ts @@ -0,0 +1,197 @@ +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 { Logger } from '@metamask/logger'; +import type { + Baggage, + ClusterConfig, + SubclusterLaunchResult, +} from '@metamask/ocap-kernel'; + +import { makeBaggageStorageAdapter } from './storage/baggage-adapter.ts'; +import { CapletController } from '../controllers/caplet/caplet-controller.ts'; +import type { CapletControllerFacet } from '../controllers/caplet/index.ts'; +import type { StorageAdapter } from '../controllers/storage/types.ts'; + +/** + * Vat powers provided to the controller vat. + */ +type VatPowers = { + logger?: Logger; +}; + +/** + * Kernel facet interface for system vat operations. + */ +type KernelFacet = { + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; + getPresence: (kref: string, iface?: string) => Promise; +}; + +/** + * Services provided to the controller vat. + */ +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.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; + logger: Logger; + resolve: (facet: CapletControllerFacet) => void; + reject: (error: unknown) => void; +}): Promise { + const { kernelFacet, storageAdapter, logger, resolve, reject } = options; + + try { + const capletFacet = await CapletController.make( + { logger: logger.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => + E(kernelFacet).launchSubcluster(config), + terminateSubcluster: async (subclusterId: string): Promise => + E(kernelFacet).terminateSubcluster(subclusterId), + getVatRoot: async (krefString: string): Promise => + E(kernelFacet).getPresence(krefString, 'vatRoot'), + }, + ); + resolve(capletFacet); + } catch (error) { + reject(error); + throw error; + } +} + +/** + * Controller 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 logger = (vatPowers.logger ?? new Logger()).subLogger({ + tags: ['controller-vat'], + }); + + // Create baggage-backed storage adapter + const storageAdapter = makeBaggageStorageAdapter(baggage); + + // 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 persistedKernelFacet: KernelFacet | undefined = baggage.has( + 'kernelFacet', + ) + ? (baggage.get('kernelFacet') as KernelFacet) + : undefined; + + // If we have a persisted kernelFacet, initialize the controller immediately + if (persistedKernelFacet) { + logger.info('Restoring controller from baggage'); + // Fire-and-forget: the promise kit will be resolved/rejected when initialization completes + initializeCapletController({ + kernelFacet: persistedKernelFacet, + storageAdapter, + logger, + resolve: resolveCapletFacet, + reject: rejectCapletFacet, + }).catch((error) => { + logger.error('Failed to restore controller from baggage:', error); + rejectCapletFacet(error); + }); + } + + // Define delegating methods for caplet operations + const capletMethods = defineMethods(capletFacetP, [ + 'install', + 'uninstall', + 'list', + 'get', + 'getCapletRoot', + ]); + + return makeDefaultExo('omnium-controllers', { + /** + * Initialize the controller 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'); + } + + // Store in baggage for persistence across restarts + baggage.init('kernelFacet', kernelFacet); + + await initializeCapletController({ + kernelFacet, + storageAdapter, + logger, + resolve: resolveCapletFacet, + reject: rejectCapletFacet, + }); + }, + + ...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.test.ts b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts new file mode 100644 index 000000000..c4aa556b4 --- /dev/null +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.test.ts @@ -0,0 +1,147 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +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); + }), + delete: vi.fn((key: string) => { + store.delete(key); + }), + keys: vi.fn(() => store.keys()), + _store: store, // For test inspection + }; +} + +describe('makeBaggageStorageAdapter', () => { + let baggage: ReturnType; + let adapter: ReturnType; + + beforeEach(() => { + baggage = makeMockBaggage(); + adapter = makeBaggageStorageAdapter(baggage as unknown as 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' }); + }); + + it('returns undefined for deleted key', async () => { + await adapter.set('to-delete', { data: 'test' }); + await adapter.delete('to-delete'); + + expect(baggage._store.has('to-delete')).toBe(false); + + const result = await adapter.get('to-delete'); + expect(result).toBeUndefined(); + }); + }); + + 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 () => { + await adapter.set('existing-key', { value: 1 }); + + await adapter.set('existing-key', { value: 2 }); + + expect(baggage.set).toHaveBeenCalled(); + expect(baggage._store.get('existing-key')).toStrictEqual({ value: 2 }); + }); + + it('re-sets previously deleted key', async () => { + await adapter.set('reused-key', { original: true }); + expect(await adapter.keys()).toContain('reused-key'); + + await adapter.delete('reused-key'); + expect(await adapter.keys()).not.toContain('reused-key'); + expect(baggage._store.has('reused-key')).toBe(false); + + 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', () => { + it('removes key from baggage', async () => { + await adapter.set('to-delete', { data: 'test' }); + await adapter.delete('to-delete'); + + 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'); + }); + + it('does nothing for non-existent key', async () => { + await adapter.delete('nonexistent'); + expect(baggage.delete).not.toHaveBeenCalled(); + 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..939c02f9c --- /dev/null +++ b/packages/omnium-gatherum/src/vats/storage/baggage-adapter.ts @@ -0,0 +1,45 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from '../../controllers/storage/types.ts'; + +/** + * Create a StorageAdapter implementation backed by vat baggage. + * Provides synchronous persistence (baggage writes are durable). + * + * @param baggage - The vat baggage store. + * @returns A StorageAdapter backed by baggage. + */ +export function makeBaggageStorageAdapter(baggage: Baggage): StorageAdapter { + 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 { + if (baggage.has(key)) { + baggage.set(key, harden(value)); + } else { + baggage.init(key, harden(value)); + } + }, + + async delete(key: string): Promise { + if (baggage.has(key)) { + baggage.delete(key); + } + }, + + async keys(prefix?: string): Promise { + const allKeys = [...baggage.keys()]; + if (!prefix) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }); +} +harden(makeBaggageStorageAdapter); 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/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(); - }); -}); 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 diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 57abda3b8..97c8fdd3f 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', + // Controller vat bundle (system vat for kernel services) + { + src: 'packages/omnium-gatherum/src/vats/controller-vat.bundle', + dest: './', + rename: 'controller-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..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:^" @@ -3927,6 +3928,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"