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..f48d2f9cd 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,34 @@ 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 = { + evaluateVat: vi.fn(), + 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/evaluate-vat.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts new file mode 100644 index 000000000..ef5a48e69 --- /dev/null +++ b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts @@ -0,0 +1,35 @@ +import type { Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { evaluateVatHandler } from './evaluate-vat.ts'; + +describe('evaluateVatHandler', () => { + let mockKernel: Kernel; + + beforeEach(() => { + mockKernel = { + evaluateVat: vi.fn().mockResolvedValue({ success: true, value: 2 }), + } as unknown as Kernel; + }); + + it('evaluates code in a vat and returns result', async () => { + const params = { id: 'v0', code: '1 + 1' } as const; + const result = await evaluateVatHandler.implementation( + { kernel: mockKernel }, + params, + ); + + expect(mockKernel.evaluateVat).toHaveBeenCalledWith(params.id, params.code); + expect(result).toStrictEqual({ success: true, value: 2 }); + }); + + it('propagates errors from kernel.evaluateVat', async () => { + const error = new Error('Evaluate failed'); + vi.mocked(mockKernel.evaluateVat).mockRejectedValueOnce(error); + + const params = { id: 'v0', code: 'bad code' } as const; + await expect( + evaluateVatHandler.implementation({ kernel: mockKernel }, params), + ).rejects.toThrow(error); + }); +}); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts new file mode 100644 index 000000000..b7d1e84e9 --- /dev/null +++ b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts @@ -0,0 +1,39 @@ +import type { Handler, MethodSpec } from '@metamask/kernel-rpc-methods'; +import type { Kernel, VatId } from '@metamask/ocap-kernel'; +import { VatIdStruct } from '@metamask/ocap-kernel'; +import { vatMethodSpecs } from '@metamask/ocap-kernel/rpc'; +import type { EvaluateResult } from '@metamask/ocap-kernel/rpc'; +import { object, string } from '@metamask/superstruct'; + +export type EvaluateVatHooks = { + kernel: Kernel; +}; + +type EvaluateVatParams = { id: VatId; code: string }; + +export type EvaluateVatSpec = MethodSpec< + 'evaluateVat', + EvaluateVatParams, + EvaluateResult +>; + +export const evaluateVatSpec = { + method: 'evaluateVat', + params: object({ id: VatIdStruct, code: string() }), + result: vatMethodSpecs.evaluate.result, +} as const as EvaluateVatSpec; + +export type EvaluateVatHandler = Handler< + 'evaluateVat', + EvaluateVatParams, + Promise, + EvaluateVatHooks +>; + +export const evaluateVatHandler: EvaluateVatHandler = { + ...evaluateVatSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }, params): Promise => { + return kernel.evaluateVat(params.id, params.code); + }, +} as const as EvaluateVatHandler; diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts index 9388d6f49..c0df7283e 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts @@ -5,6 +5,7 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -39,6 +40,7 @@ describe('handlers/index', () => { it('should export all handler functions', () => { expect(rpcHandlers).toStrictEqual({ clearState: clearStateHandler, + evaluateVat: evaluateVatHandler, executeDBQuery: executeDBQueryHandler, getStatus: getStatusHandler, pingVat: pingVatHandler, @@ -68,6 +70,7 @@ describe('handlers/index', () => { it('should export all method specs', () => { expect(rpcMethodSpecs).toStrictEqual({ clearState: clearStateSpec, + evaluateVat: evaluateVatSpec, executeDBQuery: executeDBQuerySpec, getStatus: getStatusSpec, pingVat: pingVatSpec, diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/index.ts b/packages/kernel-browser-runtime/src/rpc-handlers/index.ts index a08fb9ab4..14b1a96f1 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/index.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/index.ts @@ -3,6 +3,7 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -37,6 +38,7 @@ import { terminateVatHandler, terminateVatSpec } from './terminate-vat.ts'; */ export const rpcHandlers = { clearState: clearStateHandler, + evaluateVat: evaluateVatHandler, executeDBQuery: executeDBQueryHandler, getStatus: getStatusHandler, pingVat: pingVatHandler, @@ -53,6 +55,7 @@ export const rpcHandlers = { terminateSubcluster: terminateSubclusterHandler, } as { clearState: typeof clearStateHandler; + evaluateVat: typeof evaluateVatHandler; executeDBQuery: typeof executeDBQueryHandler; getStatus: typeof getStatusHandler; pingVat: typeof pingVatHandler; @@ -74,6 +77,7 @@ export const rpcHandlers = { */ export const rpcMethodSpecs = { clearState: clearStateSpec, + evaluateVat: evaluateVatSpec, executeDBQuery: executeDBQuerySpec, getStatus: getStatusSpec, pingVat: pingVatSpec, @@ -90,6 +94,7 @@ export const rpcMethodSpecs = { terminateSubcluster: terminateSubclusterSpec, } as { clearState: typeof clearStateSpec; + evaluateVat: typeof evaluateVatSpec; executeDBQuery: typeof executeDBQuerySpec; getStatus: typeof getStatusSpec; pingVat: typeof pingVatSpec; 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/kernel-ui/src/App.tsx b/packages/kernel-ui/src/App.tsx index c0a7c1fca..1d30286d5 100644 --- a/packages/kernel-ui/src/App.tsx +++ b/packages/kernel-ui/src/App.tsx @@ -12,6 +12,7 @@ import { MessagePanel } from './components/MessagePanel.tsx'; import { ObjectRegistry } from './components/ObjectRegistry.tsx'; import { RemoteComms } from './components/RemoteComms.tsx'; import { Tabs } from './components/shared/Tabs.tsx'; +import { VatRepl } from './components/VatRepl.tsx'; import { PanelProvider } from './context/PanelContext.tsx'; import { useDarkMode } from './hooks/useDarkMode.ts'; import { useStream } from './hooks/useStream.ts'; @@ -40,6 +41,11 @@ const tabs: NonEmptyArray<{ value: 'remote-comms', component: , }, + { + label: 'Vat REPL', + value: 'repl', + component: , + }, ]; export const App: React.FC = () => { diff --git a/packages/kernel-ui/src/components/VatRepl.test.tsx b/packages/kernel-ui/src/components/VatRepl.test.tsx new file mode 100644 index 000000000..70b37d344 --- /dev/null +++ b/packages/kernel-ui/src/components/VatRepl.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { VatRepl } from './VatRepl.tsx'; +import { usePanelContext } from '../context/PanelContext.tsx'; +import type { PanelContextType } from '../context/PanelContext.tsx'; +import { useEvaluate } from '../hooks/useEvaluate.ts'; + +vi.mock('../context/PanelContext.tsx', () => ({ + usePanelContext: vi.fn(), +})); + +vi.mock('../hooks/useEvaluate.ts', () => ({ + useEvaluate: vi.fn(), +})); + +describe('VatRepl Component', () => { + const mockLogMessage = vi.fn(); + const mockEvaluateVat = vi.fn(); + const mockCallKernelMethod = vi.fn(); + + const mockPanelContext: PanelContextType = { + callKernelMethod: mockCallKernelMethod, + status: { + vats: [ + { + id: 'v1', + config: { sourceSpec: 'test.js' }, + subclusterId: 's1', + }, + { + id: 'v2', + config: { sourceSpec: 'test2.js' }, + subclusterId: 's1', + }, + ], + subclusters: [], + remoteComms: { isInitialized: false }, + }, + logMessage: mockLogMessage, + messageContent: '', + setMessageContent: vi.fn(), + panelLogs: [], + clearLogs: vi.fn(), + isLoading: false, + objectRegistry: null, + setObjectRegistry: vi.fn(), + }; + + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.mocked(usePanelContext).mockReturnValue(mockPanelContext); + vi.mocked(useEvaluate).mockReturnValue({ + evaluateVat: mockEvaluateVat, + }); + }); + + it('renders vat selector with available vats', () => { + render(); + const selector = screen.getByTestId('vat-selector'); + expect(selector).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'v1' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'v2' })).toBeInTheDocument(); + }); + + it('renders code input and evaluate button', () => { + render(); + expect(screen.getByTestId('code-input')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Evaluate' }), + ).toBeInTheDocument(); + }); + + it('disables evaluate button when no vat selected or no code', () => { + render(); + const button = screen.getByRole('button', { name: 'Evaluate' }); + expect(button).toBeDisabled(); + }); + + it('evaluates code and displays success result', async () => { + mockEvaluateVat.mockResolvedValueOnce({ success: true, value: 42 }); + render(); + + await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1'); + await userEvent.type(screen.getByTestId('code-input'), '21 * 2'); + await userEvent.click(screen.getByRole('button', { name: 'Evaluate' })); + + expect(mockEvaluateVat).toHaveBeenCalledWith('v1', '21 * 2'); + + await waitFor(() => { + expect(screen.getByTestId('result-display')).toBeInTheDocument(); + expect(screen.getByTestId('result-display')).toHaveTextContent('42'); + }); + + expect(mockLogMessage).toHaveBeenCalledWith( + 'Evaluated in v1: 42', + 'success', + ); + }); + + it('evaluates code and displays error result', async () => { + mockEvaluateVat.mockResolvedValueOnce({ + success: false, + error: 'ReferenceError: x is not defined', + }); + render(); + + await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1'); + await userEvent.type(screen.getByTestId('code-input'), 'x'); + await userEvent.click(screen.getByRole('button', { name: 'Evaluate' })); + + await waitFor(() => { + expect(screen.getByTestId('result-display')).toHaveTextContent( + 'ReferenceError: x is not defined', + ); + }); + + expect(mockLogMessage).toHaveBeenCalledWith( + 'Evaluation error in v1: ReferenceError: x is not defined', + 'error', + ); + }); + + it('logs error when evaluation promise rejects', async () => { + mockEvaluateVat.mockRejectedValueOnce(new Error('Network error')); + render(); + + await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1'); + await userEvent.type(screen.getByTestId('code-input'), '1 + 1'); + await userEvent.click(screen.getByRole('button', { name: 'Evaluate' })); + + await waitFor(() => { + expect(mockLogMessage).toHaveBeenCalledWith( + 'Failed to evaluate: Network error', + 'error', + ); + }); + }); + + it('renders empty vat list when no status', () => { + vi.mocked(usePanelContext).mockReturnValue({ + ...mockPanelContext, + status: undefined, + }); + render(); + const selector = screen.getByTestId('vat-selector'); + // Only the disabled placeholder option + expect(selector.querySelectorAll('option')).toHaveLength(1); + }); +}); diff --git a/packages/kernel-ui/src/components/VatRepl.tsx b/packages/kernel-ui/src/components/VatRepl.tsx new file mode 100644 index 000000000..788640207 --- /dev/null +++ b/packages/kernel-ui/src/components/VatRepl.tsx @@ -0,0 +1,150 @@ +import { + Button, + ButtonVariant, + ButtonSize, + Box, + Text as TextComponent, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react'; +import type { EvaluateResult } from '@metamask/ocap-kernel/rpc'; +import { useState, useCallback } from 'react'; + +import { usePanelContext } from '../context/PanelContext.tsx'; +import { useEvaluate } from '../hooks/useEvaluate.ts'; + +/** + * @returns The VatRepl component. + */ +export const VatRepl: React.FC = () => { + const { status, logMessage } = usePanelContext(); + const { evaluateVat } = useEvaluate(); + const [selectedVat, setSelectedVat] = useState(''); + const [code, setCode] = useState(''); + const [result, setResult] = useState(null); + const [isEvaluating, setIsEvaluating] = useState(false); + + const vats = status?.vats ?? []; + + const onEvaluate = useCallback(() => { + if (!selectedVat || !code.trim()) { + return; + } + setIsEvaluating(true); + setResult(null); + evaluateVat(selectedVat, code) + .then((evalResult: EvaluateResult) => { + setResult(evalResult); + if (evalResult.success) { + return logMessage( + `Evaluated in ${selectedVat}: ${JSON.stringify(evalResult.value)}`, + 'success', + ); + } + return logMessage( + `Evaluation error in ${selectedVat}: ${evalResult.error}`, + 'error', + ); + }) + .catch((error: Error) => { + logMessage(`Failed to evaluate: ${error.message}`, 'error'); + }) + .finally(() => { + setIsEvaluating(false); + }); + }, [selectedVat, code, evaluateVat, logMessage]); + + return ( + + + + + + Select Vat + + + + + + + + + Code + +