diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index f485dd89e..f90a0b5f4 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,6 +1,7 @@ +import { isJsonRpcResponse } from '@metamask/utils'; import type { Json } from '@metamask/utils'; -import { KernelCommandMethod, isKernelCommandReply } from '@ocap/kernel'; -import type { KernelCommand } from '@ocap/kernel'; +import { kernelMethodSpecs } from '@ocap/kernel/rpc'; +import { RpcClient } from '@ocap/rpc-methods'; import { ChromeRuntimeDuplexStream } from '@ocap/streams/browser'; import { delay, Logger } from '@ocap/utils'; @@ -29,23 +30,23 @@ async function main(): Promise { 'offscreen', ); - /** - * Send a command to the offscreen document. - * - * @param command - The command to send. - */ - const sendClusterCommand = async (command: KernelCommand): Promise => { - await offscreenStream.write(command); + const rpcClient = new RpcClient( + kernelMethodSpecs, + async (request) => { + await offscreenStream.write(request); + }, + 'background:', + ); + + const ping = async (): Promise => { + const result = await rpcClient.call('ping', []); + logger.info(result); }; // globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js Object.defineProperties(globalThis.kernel, { ping: { - value: async () => - sendClusterCommand({ - method: KernelCommandMethod.ping, - params: [], - }), + value: ping, }, sendMessage: { value: async (message: Json) => await offscreenStream.write(message), @@ -55,29 +56,16 @@ async function main(): Promise { // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - sendClusterCommand({ - method: KernelCommandMethod.ping, - params: [], - }).catch(logger.error); + ping().catch(logger.error); }); // Handle replies from the offscreen document for await (const message of offscreenStream) { - if (!isKernelCommandReply(message)) { + if (!isJsonRpcResponse(message)) { logger.error('Background received unexpected message', message); continue; } - - switch (message.method) { - case KernelCommandMethod.ping: - logger.info('Background received ping reply', message.params); - break; - default: - logger.error( - // @ts-expect-error Compile-time exhaustiveness check - `Background received unexpected command method: "${message.method.valueOf()}"`, - ); - } + rpcClient.handleResponse(message.id as string, message); } throw new Error('Offscreen connection closed unexpectedly'); diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 6e0fce247..866536c5b 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -1,10 +1,10 @@ -import { isVatCommand, VatSupervisor } from '@ocap/kernel'; -import type { VatCommand, VatCommandReply } from '@ocap/kernel'; +import { VatSupervisor } from '@ocap/kernel'; import { MessagePortDuplexStream, receiveMessagePort, } from '@ocap/streams/browser'; -import { Logger } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; +import { isJsonRpcMessage, Logger } from '@ocap/utils'; const logger = new Logger('iframe'); @@ -14,13 +14,13 @@ main().catch(logger.error); * The main function for the iframe. */ async function main(): Promise { - const commandStream = await receiveMessagePort( + const kernelStream = await receiveMessagePort( (listener) => addEventListener('message', listener), (listener) => removeEventListener('message', listener), ).then(async (port) => - MessagePortDuplexStream.make( + MessagePortDuplexStream.make( port, - isVatCommand, + isJsonRpcMessage, ), ); @@ -30,7 +30,7 @@ async function main(): Promise { // eslint-disable-next-line no-new new VatSupervisor({ id: vatId, - commandStream, + kernelStream, }); logger.info('VatSupervisor initialized with vatId:', vatId); diff --git a/packages/extension/src/kernel-integration/VatWorkerClient.test.ts b/packages/extension/src/kernel-integration/VatWorkerClient.test.ts index 5fec4bd2b..db50619ae 100644 --- a/packages/extension/src/kernel-integration/VatWorkerClient.test.ts +++ b/packages/extension/src/kernel-integration/VatWorkerClient.test.ts @@ -10,15 +10,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { VatWorkerClientStream } from './VatWorkerClient.ts'; import { ExtensionVatWorkerClient } from './VatWorkerClient.ts'; -vi.mock('@ocap/kernel', async () => ({ - isVatCommandReply: vi.fn(() => true), - VatWorkerServiceCommandMethod: { - launch: 'launch', - terminate: 'terminate', - terminateAll: 'terminateAll', - }, -})); - vi.mock('@ocap/streams/browser', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-shadow const { TestDuplexStream } = await import('@ocap/test-utils/streams'); @@ -109,15 +100,15 @@ describe('ExtensionVatWorkerClient', () => { await expect(resultP).rejects.toThrow('foo'); }); - it('calls logger.error when receiving an unexpected reply', async () => { - const errorSpy = vi.spyOn(clientLogger, 'error'); + it('calls logger.debug when receiving an unexpected reply', async () => { + const debugSpy = vi.spyOn(clientLogger, 'debug'); const unexpectedReply = makeNullReply('m9'); await stream.receiveInput(unexpectedReply); await delay(10); - expect(errorSpy).toHaveBeenCalledOnce(); - expect(errorSpy).toHaveBeenLastCalledWith( + expect(debugSpy).toHaveBeenCalledOnce(); + expect(debugSpy).toHaveBeenLastCalledWith( 'Received response with unexpected id "m9".', ); }); diff --git a/packages/extension/src/kernel-integration/VatWorkerClient.ts b/packages/extension/src/kernel-integration/VatWorkerClient.ts index c7d063f26..dc25c33af 100644 --- a/packages/extension/src/kernel-integration/VatWorkerClient.ts +++ b/packages/extension/src/kernel-integration/VatWorkerClient.ts @@ -4,15 +4,8 @@ import type { JsonRpcRequest, JsonRpcResponse, } from '@metamask/utils'; -import { isVatCommandReply } from '@ocap/kernel'; -import type { - VatWorkerManager, - VatId, - VatConfig, - VatCommand, - VatCommandReply, -} from '@ocap/kernel'; -import { vatWorkerService } from '@ocap/kernel/rpc'; +import type { VatWorkerManager, VatId, VatConfig } from '@ocap/kernel'; +import { vatWorkerServiceMethodSpecs } from '@ocap/kernel/rpc'; import { RpcClient } from '@ocap/rpc-methods'; import type { DuplexStream } from '@ocap/streams'; import { @@ -23,8 +16,8 @@ import type { PostMessageEnvelope, PostMessageTarget, } from '@ocap/streams/browser'; -import type { Logger } from '@ocap/utils'; -import { makeLogger, stringify } from '@ocap/utils'; +import type { JsonRpcMessage, Logger } from '@ocap/utils'; +import { isJsonRpcMessage, makeLogger, stringify } from '@ocap/utils'; // Appears in the docs. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -40,7 +33,7 @@ export class ExtensionVatWorkerClient implements VatWorkerManager { readonly #stream: VatWorkerClientStream; - readonly #rpcClient: RpcClient; + readonly #rpcClient: RpcClient; readonly #portMap: Map; @@ -66,7 +59,7 @@ export class ExtensionVatWorkerClient implements VatWorkerManager { this.#portMap = new Map(); this.#logger = logger ?? makeLogger('[vat worker client]'); this.#rpcClient = new RpcClient( - vatWorkerService.methodSpecs, + vatWorkerServiceMethodSpecs, async (request) => { if (request.method === 'launch') { this.#portMap.set(request.id, undefined); @@ -112,7 +105,7 @@ export class ExtensionVatWorkerClient implements VatWorkerManager { async launch( vatId: VatId, vatConfig: VatConfig, - ): Promise> { + ): Promise> { const [id] = await this.#rpcClient.callAndGetId('launch', { vatId, vatConfig, @@ -124,9 +117,9 @@ export class ExtensionVatWorkerClient implements VatWorkerManager { ); } this.#portMap.delete(id); - return await MessagePortDuplexStream.make( + return await MessagePortDuplexStream.make( port, - isVatCommandReply, + isJsonRpcMessage, ); } diff --git a/packages/extension/src/kernel-integration/VatWorkerServer.ts b/packages/extension/src/kernel-integration/VatWorkerServer.ts index 332036129..d1def382e 100644 --- a/packages/extension/src/kernel-integration/VatWorkerServer.ts +++ b/packages/extension/src/kernel-integration/VatWorkerServer.ts @@ -9,7 +9,7 @@ import type { import { VatAlreadyExistsError, VatNotFoundError } from '@ocap/errors'; import type { VatId, VatConfig } from '@ocap/kernel'; import type { VatWorkerServiceMethod } from '@ocap/kernel/rpc'; -import { vatWorkerService } from '@ocap/kernel/rpc'; +import { vatWorkerServiceMethodSpecs } from '@ocap/kernel/rpc'; import type { ExtractParams } from '@ocap/rpc-methods'; import { PostMessageDuplexStream } from '@ocap/streams/browser'; import type { @@ -105,7 +105,7 @@ export class ExtensionVatWorkerService { } #assertHasMethod(method: string): asserts method is VatWorkerServiceMethod { - if (!hasProperty(vatWorkerService.methodSpecs, method)) { + if (!hasProperty(vatWorkerServiceMethodSpecs, method)) { throw rpcErrors.methodNotFound(); } } @@ -115,9 +115,9 @@ export class ExtensionVatWorkerService { params: unknown, ): asserts params is ExtractParams< Method, - typeof vatWorkerService.methodSpecs + typeof vatWorkerServiceMethodSpecs > { - vatWorkerService.methodSpecs[method].params.assert(params); + vatWorkerServiceMethodSpecs[method].params.assert(params); } async #handleMessage(event: MessageEvent): Promise { diff --git a/packages/extension/src/kernel-integration/handlers/clear-state.ts b/packages/extension/src/kernel-integration/handlers/clear-state.ts index 49be4f013..5211eefdd 100644 --- a/packages/extension/src/kernel-integration/handlers/clear-state.ts +++ b/packages/extension/src/kernel-integration/handlers/clear-state.ts @@ -4,7 +4,7 @@ import type { Kernel } from '@ocap/kernel'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; import { EmptyJsonArray } from '@ocap/utils'; -export const clearStateSpec: MethodSpec<'clearState', Json[], null> = { +export const clearStateSpec: MethodSpec<'clearState', Json[], Promise> = { method: 'clearState', params: EmptyJsonArray, result: literal(null), @@ -15,7 +15,7 @@ export type ClearStateHooks = { kernel: Pick }; export const clearStateHandler: Handler< 'clearState', Json[], - null, + Promise, ClearStateHooks > = { ...clearStateSpec, diff --git a/packages/extension/src/kernel-integration/handlers/collect-garbage.test.ts b/packages/extension/src/kernel-integration/handlers/collect-garbage.test.ts new file mode 100644 index 000000000..5cc7c0872 --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/collect-garbage.test.ts @@ -0,0 +1,34 @@ +import type { Kernel } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { collectGarbageHandler } from './collect-garbage.ts'; + +describe('collectGarbageHandler', () => { + let mockKernel: Kernel; + + beforeEach(() => { + mockKernel = { + collectGarbage: vi.fn(), + } as unknown as Kernel; + }); + + it('collects garbage', () => { + const result = collectGarbageHandler.implementation( + { kernel: mockKernel }, + [], + ); + + expect(mockKernel.collectGarbage).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('should propagate errors from collectGarbage', async () => { + const error = new Error('Collect garbage failed'); + vi.mocked(mockKernel.collectGarbage).mockImplementationOnce(() => { + throw error; + }); + expect(() => + collectGarbageHandler.implementation({ kernel: mockKernel }, []), + ).toThrow(error); + }); +}); diff --git a/packages/extension/src/kernel-integration/handlers/collect-garbage.ts b/packages/extension/src/kernel-integration/handlers/collect-garbage.ts index 458ddb19a..0cca4e443 100644 --- a/packages/extension/src/kernel-integration/handlers/collect-garbage.ts +++ b/packages/extension/src/kernel-integration/handlers/collect-garbage.ts @@ -23,7 +23,7 @@ export const collectGarbageHandler: Handler< > = { ...collectGarbageSpec, hooks: { kernel: true }, - implementation: async ({ kernel }): Promise => { + implementation: ({ kernel }) => { kernel.collectGarbage(); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/execute-db-query.ts b/packages/extension/src/kernel-integration/handlers/execute-db-query.ts index ab52071b9..bdad60af5 100644 --- a/packages/extension/src/kernel-integration/handlers/execute-db-query.ts +++ b/packages/extension/src/kernel-integration/handlers/execute-db-query.ts @@ -4,7 +4,7 @@ import type { MethodSpec, Handler } from '@ocap/rpc-methods'; export const executeDBQuerySpec: MethodSpec< 'executeDBQuery', { sql: string }, - Record[] + Promise[]> > = { method: 'executeDBQuery', params: object({ @@ -20,7 +20,7 @@ export type ExecuteDBQueryHooks = { export const executeDBQueryHandler: Handler< 'executeDBQuery', { sql: string }, - Record[], + Promise[]>, ExecuteDBQueryHooks > = { ...executeDBQuerySpec, diff --git a/packages/extension/src/kernel-integration/handlers/get-status.test.ts b/packages/extension/src/kernel-integration/handlers/get-status.test.ts index 2a1a24850..94f0b85a3 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.test.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.test.ts @@ -16,25 +16,22 @@ describe('getStatusHandler', () => { }); }); - it('should return vats status and cluster config', async () => { + it('should return vats status and cluster config', () => { vi.mocked(mockKernel.getVats).mockReturnValueOnce([]); - const result = await getStatusHandler.implementation( - { kernel: mockKernel }, - [], - ); + const result = getStatusHandler.implementation({ kernel: mockKernel }, []); expect(mockKernel.getVats).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ vats: [], clusterConfig: { foo: 'bar' } }); }); - it('should propagate errors from getVats', async () => { + it('should propagate errors from getVats', () => { const error = new Error('Status check failed'); vi.mocked(mockKernel.getVats).mockImplementationOnce(() => { throw error; }); - await expect( + expect(() => getStatusHandler.implementation({ kernel: mockKernel }, []), - ).rejects.toThrow(error); + ).toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/get-status.ts b/packages/extension/src/kernel-integration/handlers/get-status.ts index 810122ed3..eefeb84ff 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.ts @@ -43,9 +43,7 @@ export const getStatusHandler: Handler< > = { ...getStatusSpec, hooks: { kernel: true }, - implementation: async ({ - kernel, - }: GetStatusHooks): Promise => ({ + implementation: ({ kernel }: GetStatusHooks): KernelStatus => ({ vats: kernel.getVats(), clusterConfig: kernel.clusterConfig, }), diff --git a/packages/extension/src/kernel-integration/handlers/launch-vat.ts b/packages/extension/src/kernel-integration/handlers/launch-vat.ts index fe4c7a0ac..f1b67c9c4 100644 --- a/packages/extension/src/kernel-integration/handlers/launch-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/launch-vat.ts @@ -3,7 +3,11 @@ import { VatConfigStruct } from '@ocap/kernel'; import type { Kernel, VatConfig } from '@ocap/kernel'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -export const launchVatSpec: MethodSpec<'launchVat', VatConfig, null> = { +export const launchVatSpec: MethodSpec< + 'launchVat', + VatConfig, + Promise +> = { method: 'launchVat', params: VatConfigStruct, result: literal(null), @@ -16,15 +20,12 @@ export type LaunchVatHooks = { export const launchVatHandler: Handler< 'launchVat', VatConfig, - null, + Promise, LaunchVatHooks > = { ...launchVatSpec, hooks: { kernel: true }, - implementation: async ( - { kernel }: LaunchVatHooks, - params: VatConfig, - ): Promise => { + implementation: async ({ kernel }, params): Promise => { await kernel.launchVat(params); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/reload-config.ts b/packages/extension/src/kernel-integration/handlers/reload-config.ts index ec77fdd3d..3aeba8d84 100644 --- a/packages/extension/src/kernel-integration/handlers/reload-config.ts +++ b/packages/extension/src/kernel-integration/handlers/reload-config.ts @@ -3,7 +3,11 @@ import type { Kernel } from '@ocap/kernel'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; import { EmptyJsonArray } from '@ocap/utils'; -export const reloadConfigSpec: MethodSpec<'reload', EmptyJsonArray, null> = { +export const reloadConfigSpec: MethodSpec< + 'reload', + EmptyJsonArray, + Promise +> = { method: 'reload', params: EmptyJsonArray, result: literal(null), @@ -14,7 +18,7 @@ export type ReloadConfigHooks = { kernel: Pick }; export const reloadConfigHandler: Handler< 'reload', EmptyJsonArray, - null, + Promise, ReloadConfigHooks > = { ...reloadConfigSpec, diff --git a/packages/extension/src/kernel-integration/handlers/restart-vat.ts b/packages/extension/src/kernel-integration/handlers/restart-vat.ts index c10e31fa7..b958e6cc6 100644 --- a/packages/extension/src/kernel-integration/handlers/restart-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/restart-vat.ts @@ -3,7 +3,11 @@ import { VatIdStruct } from '@ocap/kernel'; import type { Kernel, VatId } from '@ocap/kernel'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -export const restartVatSpec: MethodSpec<'restartVat', { id: VatId }, null> = { +export const restartVatSpec: MethodSpec< + 'restartVat', + { id: VatId }, + Promise +> = { method: 'restartVat', params: object({ id: VatIdStruct }), result: literal(null), @@ -14,7 +18,7 @@ export type RestartVatHooks = { kernel: Pick }; export const restartVatHandler: Handler< 'restartVat', { id: VatId }, - null, + Promise, RestartVatHooks > = { ...restartVatSpec, diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts index ab89fdd7d..4b91a3412 100644 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts +++ b/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts @@ -19,29 +19,43 @@ describe('sendVatCommandHandler', () => { { kernel: mockKernel }, { id: vatId, - payload: { method: 'ping', params: [] }, + payload: { + id: 'test-id', + jsonrpc: '2.0', + method: 'ping', + params: [], + }, }, ); expect(mockKernel.sendVatCommand).toHaveBeenCalledWith(vatId, { + id: 'test-id', + jsonrpc: '2.0', method: 'ping', params: [], }); expect(result).toStrictEqual({ result: 'foo' }); }); - it('throws if payload is not a valid kernel command', async () => { + it('forwards errors from hooks', async () => { const vatId = 'vat1' as VatId; + vi.mocked(mockKernel.sendVatCommand).mockRejectedValueOnce( + new Error('foo'), + ); await expect( sendVatCommandHandler.implementation( { kernel: mockKernel }, { id: vatId, - payload: { notACommand: true }, + payload: { + id: 'test-id', + jsonrpc: '2.0', + method: 'ping', + params: [], + }, }, ), - ).rejects.toThrow('Invalid command payload'); - expect(mockKernel.sendVatCommand).not.toHaveBeenCalled(); + ).rejects.toThrow('foo'); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts index 2efd68479..ebd3ffa3b 100644 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts +++ b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts @@ -1,39 +1,50 @@ import { object } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import { UnsafeJsonStruct } from '@metamask/utils'; import type { Json } from '@metamask/utils'; -import { isVatCommandPayloadUI, VatIdStruct } from '@ocap/kernel'; +import { VatIdStruct } from '@ocap/kernel'; import type { Kernel, VatId } from '@ocap/kernel'; +import { UiMethodRequestStruct } from '@ocap/kernel/rpc'; +import type { UiMethodRequest } from '@ocap/kernel/rpc'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; export const sendVatCommandSpec: MethodSpec< 'sendVatCommand', - { id: VatId; payload: Json }, - { result: Json } + { id: VatId; payload: UiMethodRequest }, + Promise<{ result: Json }> > = { method: 'sendVatCommand', - // TODO:rekm Use a more specific struct for the payload - params: object({ id: VatIdStruct, payload: UnsafeJsonStruct }), + params: object({ id: VatIdStruct, payload: UiMethodRequestStruct }), result: object({ result: UnsafeJsonStruct }), }; +export type SendVatCommandParams = Infer<(typeof sendVatCommandSpec)['params']>; + export type SendVatCommandHooks = { kernel: Pick; }; export const sendVatCommandHandler: Handler< 'sendVatCommand', - { id: VatId; payload: Json }, - { result: Json }, + { id: VatId; payload: UiMethodRequest }, + Promise<{ result: Json }>, SendVatCommandHooks > = { ...sendVatCommandSpec, hooks: { kernel: true }, implementation: async ({ kernel }, params): Promise<{ result: Json }> => { - if (!isVatCommandPayloadUI(params.payload)) { - throw new Error('Invalid command payload'); - } - const result = await kernel.sendVatCommand(params.id, params.payload); return { result }; }, }; + +/** + * Asserts that the given params are valid for the `sendVatCommand` method. + * + * @param params - The params to assert. + */ +export function assertVatCommandParams( + params: unknown, +): asserts params is SendVatCommandParams { + sendVatCommandSpec.params.assert(params); +} diff --git a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts index 18f1a2037..ba368a53f 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts @@ -7,7 +7,7 @@ import { EmptyJsonArray } from '@ocap/utils'; export const terminateAllVatsSpec: MethodSpec< 'terminateAllVats', Json[], - null + Promise > = { method: 'terminateAllVats', params: EmptyJsonArray, @@ -21,7 +21,7 @@ export type TerminateAllVatsHooks = { export const terminateAllVatsHandler: Handler< 'terminateAllVats', Json[], - null, + Promise, TerminateAllVatsHooks > = { ...terminateAllVatsSpec, diff --git a/packages/extension/src/kernel-integration/handlers/terminate-vat.ts b/packages/extension/src/kernel-integration/handlers/terminate-vat.ts index e1d60f88a..c7eef15d9 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-vat.ts @@ -3,19 +3,22 @@ import type { Kernel, VatId } from '@ocap/kernel'; import { VatIdStruct } from '@ocap/kernel'; import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -export const terminateVatSpec: MethodSpec<'terminateVat', { id: VatId }, null> = - { - method: 'terminateVat', - params: object({ id: VatIdStruct }), - result: literal(null), - }; +export const terminateVatSpec: MethodSpec< + 'terminateVat', + { id: VatId }, + Promise +> = { + method: 'terminateVat', + params: object({ id: VatIdStruct }), + result: literal(null), +}; export type TerminateVatHooks = { kernel: Pick }; export const terminateVatHandler: Handler< 'terminateVat', { id: VatId }, - null, + Promise, TerminateVatHooks > = { ...terminateVatSpec, diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts index 694d9d93c..2bc246ea2 100644 --- a/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts @@ -14,10 +14,10 @@ describe('updateClusterConfigHandler', () => { }, }); - it('should update kernel cluster config', async () => { + it('should update kernel cluster config', () => { const updateClusterConfig = vi.fn(); const testConfig = makeTestConfig(); - const result = await updateClusterConfigHandler.implementation( + const result = updateClusterConfigHandler.implementation( { updateClusterConfig }, { config: testConfig }, ); @@ -26,16 +26,16 @@ describe('updateClusterConfigHandler', () => { expect(result).toBeNull(); }); - it('should propagate errors from updateClusterConfig', async () => { + it('should propagate errors from updateClusterConfig', () => { const error = new Error('Update failed'); const updateClusterConfig = vi.fn(() => { throw error; }); - await expect( + expect(() => updateClusterConfigHandler.implementation( { updateClusterConfig }, { config: makeTestConfig() }, ), - ).rejects.toThrow(error); + ).toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts index bf12ce344..76332825e 100644 --- a/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts @@ -25,7 +25,7 @@ export const updateClusterConfigHandler: Handler< > = { ...updateClusterConfigSpec, hooks: { updateClusterConfig: true }, - implementation: async ({ updateClusterConfig }, params): Promise => { + implementation: ({ updateClusterConfig }, params): null => { updateClusterConfig(params.config); return null; }, diff --git a/packages/extension/src/kernel-integration/kernel-worker.ts b/packages/extension/src/kernel-integration/kernel-worker.ts index 2c256acc4..58abf19c5 100644 --- a/packages/extension/src/kernel-integration/kernel-worker.ts +++ b/packages/extension/src/kernel-integration/kernel-worker.ts @@ -1,10 +1,8 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { - ClusterConfig, - KernelCommand, - KernelCommandReply, -} from '@ocap/kernel'; -import { ClusterConfigStruct, isKernelCommand, Kernel } from '@ocap/kernel'; +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; +import { isJsonRpcRequest } from '@metamask/utils'; +import type { ClusterConfig } from '@ocap/kernel'; +import { ClusterConfigStruct, Kernel } from '@ocap/kernel'; import { makeSQLKernelDatabase } from '@ocap/store/sqlite/wasm'; import type { PostMessageTarget } from '@ocap/streams/browser'; import { @@ -41,9 +39,9 @@ async function main(): Promise { ); const kernelStream = await MessagePortDuplexStream.make< - KernelCommand, - KernelCommandReply - >(port, isKernelCommand); + JsonRpcRequest, + JsonRpcResponse + >(port, isJsonRpcRequest); // Initialize kernel dependencies const vatWorkerClient = ExtensionVatWorkerClient.make( diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 980da19a7..b10e75f0f 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,5 +1,5 @@ -import { isKernelCommandReply } from '@ocap/kernel'; -import type { KernelCommandReply, KernelCommand } from '@ocap/kernel'; +import { isJsonRpcRequest, isJsonRpcResponse } from '@metamask/utils'; +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import type { DuplexStream } from '@ocap/streams'; import { initializeMessageChannel, @@ -25,9 +25,9 @@ async function main(): Promise { // Create stream for messages from the background script const backgroundStream = await ChromeRuntimeDuplexStream.make< - KernelCommand, - KernelCommandReply - >(chrome.runtime, 'offscreen', 'background'); + JsonRpcRequest, + JsonRpcResponse + >(chrome.runtime, 'offscreen', 'background', isJsonRpcRequest); const { kernelStream, vatWorkerService } = await makeKernelWorker(); @@ -45,7 +45,7 @@ async function main(): Promise { * @returns The message port stream for worker communication */ async function makeKernelWorker(): Promise<{ - kernelStream: DuplexStream; + kernelStream: DuplexStream; vatWorkerService: ExtensionVatWorkerService; }> { const worker = new Worker('kernel-worker.js', { type: 'module' }); @@ -55,9 +55,9 @@ async function makeKernelWorker(): Promise<{ ); const kernelStream = await MessagePortDuplexStream.make< - KernelCommandReply, - KernelCommand - >(port, isKernelCommandReply); + JsonRpcResponse, + JsonRpcRequest + >(port, isJsonRpcResponse); const vatWorkerService = ExtensionVatWorkerService.make( worker as PostMessageTarget, diff --git a/packages/extension/src/ui/components/MessagePanel.test.tsx b/packages/extension/src/ui/components/MessagePanel.test.tsx index 5deada254..b8d197bf8 100644 --- a/packages/extension/src/ui/components/MessagePanel.test.tsx +++ b/packages/extension/src/ui/components/MessagePanel.test.tsx @@ -32,6 +32,7 @@ describe('MessagePanel Component', () => { vi.mocked(useKernelActions).mockReturnValue({ sendKernelCommand, terminateAllVats: vi.fn(), + collectGarbage: vi.fn(), clearState: vi.fn(), reload: vi.fn(), launchVat: vi.fn(), diff --git a/packages/extension/src/ui/hooks/useKernelActions.test.ts b/packages/extension/src/ui/hooks/useKernelActions.test.ts index d9c2c291a..ded8b0fe4 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.test.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.test.ts @@ -3,18 +3,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import clusterConfig from '../../vats/default-cluster.json'; +vi.mock('../../kernel-integration/handlers/send-vat-command.ts', () => ({ + assertVatCommandParams: vi.fn(), +})); + vi.mock('../context/PanelContext.tsx', () => ({ usePanelContext: vi.fn(), })); -vi.mock('@ocap/utils', () => ({ +vi.mock('@ocap/utils', async (importOriginal) => ({ + ...(await importOriginal()), stringify: JSON.stringify, })); describe('useKernelActions', () => { const mockSendMessage = vi.fn(); const mockLogMessage = vi.fn(); - const mockMessageContent = '{"test": "content"}'; + const mockMessageContent = '{"id": "v0", "payload": {"method": "test"}}'; beforeEach(async () => { const { usePanelContext } = await import('../context/PanelContext.tsx'); @@ -26,6 +31,7 @@ describe('useKernelActions', () => { status: undefined, panelLogs: [], clearLogs: vi.fn(), + isLoading: false, }); }); @@ -33,13 +39,15 @@ describe('useKernelActions', () => { it('sends message with payload', async () => { const { useKernelActions } = await import('./useKernelActions.ts'); const { result } = renderHook(() => useKernelActions()); - const expectedParams = { test: 'content' }; mockSendMessage.mockResolvedValueOnce({ success: true }); result.current.sendKernelCommand(); await waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ method: 'sendVatCommand', - params: expectedParams, + params: expect.objectContaining({ + id: 'v0', + payload: expect.any(Object), + }), }); }); }); diff --git a/packages/extension/src/ui/hooks/useKernelActions.ts b/packages/extension/src/ui/hooks/useKernelActions.ts index 652e6bf9e..57f5ced36 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.ts @@ -1,8 +1,13 @@ +import { hasProperty, isObject } from '@metamask/utils'; import type { ClusterConfig } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; import { useCallback } from 'react'; +import { assertVatCommandParams } from '../../kernel-integration/handlers/send-vat-command.ts'; +import type { SendVatCommandParams } from '../../kernel-integration/handlers/send-vat-command.ts'; import { usePanelContext } from '../context/PanelContext.tsx'; +import { nextMessageId } from '../utils.ts'; + /** * Hook for handling kernel actions. * @@ -25,7 +30,7 @@ export function useKernelActions(): { const sendKernelCommand = useCallback(() => { callKernelMethod({ method: 'sendVatCommand', - params: JSON.parse(messageContent), + params: parseCommandParams(messageContent), }) .then((result) => logMessage(stringify(result, 0), 'received')) .catch((error) => logMessage(error.message, 'error')); @@ -124,3 +129,31 @@ export function useKernelActions(): { updateClusterConfig, }; } + +/** + * Parses sendVatCommand params to the expected format. Basically, turns the payload + * into a JSON-RPC request. + * + * @param rawParams - The raw, stringified params to parse. + * @returns The parsed params. + */ +function parseCommandParams(rawParams: string): SendVatCommandParams { + const params = JSON.parse(rawParams); + if ( + isObject(params) && + isObject(params.payload) && + hasProperty(params.payload, 'method') + ) { + const parsed = { + ...params, + payload: { + ...params.payload, + id: nextMessageId(), + jsonrpc: '2.0', + }, + }; + assertVatCommandParams(parsed); + return parsed; + } + throw new Error('Invalid command params'); +} diff --git a/packages/extension/src/ui/hooks/useVats.test.ts b/packages/extension/src/ui/hooks/useVats.test.ts index 3e0063123..5921bb318 100644 --- a/packages/extension/src/ui/hooks/useVats.test.ts +++ b/packages/extension/src/ui/hooks/useVats.test.ts @@ -11,7 +11,8 @@ vi.mock('../context/PanelContext.tsx', () => ({ setupOcapKernelMock(); -vi.mock('@ocap/utils', () => ({ +vi.mock('@ocap/utils', async (importOriginal) => ({ + ...(await importOriginal()), stringify: JSON.stringify, })); @@ -95,6 +96,8 @@ describe('useVats', () => { params: { id: mockVatId, payload: { + id: expect.any(String), + jsonrpc: '2.0', method: 'ping', params: [], }, diff --git a/packages/extension/src/ui/hooks/useVats.ts b/packages/extension/src/ui/hooks/useVats.ts index 3c971acff..306400ca8 100644 --- a/packages/extension/src/ui/hooks/useVats.ts +++ b/packages/extension/src/ui/hooks/useVats.ts @@ -1,10 +1,10 @@ -import { VatCommandMethod } from '@ocap/kernel'; -import type { VatId } from '@ocap/kernel'; +import type { VatConfig, VatId } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; import { useCallback, useMemo } from 'react'; import { usePanelContext } from '../context/PanelContext.tsx'; import type { VatRecord } from '../types.ts'; +import { nextMessageId } from '../utils.ts'; /** * Hook to manage the vats state. @@ -19,15 +19,24 @@ export const useVats = (): { } => { const { callKernelMethod, status, logMessage } = usePanelContext(); + const getSource = (config: VatConfig): string => { + if ('bundleSpec' in config) { + return config.bundleSpec; + } + if ('sourceSpec' in config) { + return config.sourceSpec; + } + if ('bundleName' in config) { + return config.bundleName; + } + return 'unknown'; + }; + const vats = useMemo(() => { return ( status?.vats.map(({ id, config }) => ({ id, - source: - config?.bundleSpec ?? - config?.sourceSpec ?? - config?.bundleName ?? - 'unknown', + source: getSource(config), parameters: stringify(config?.parameters ?? {}, 0), creationOptions: stringify(config?.creationOptions ?? {}, 0), })) ?? [] @@ -44,7 +53,9 @@ export const useVats = (): { params: { id, payload: { - method: VatCommandMethod.ping, + id: nextMessageId(), + jsonrpc: '2.0', + method: 'ping', params: [], }, }, diff --git a/packages/extension/src/ui/utils.ts b/packages/extension/src/ui/utils.ts index 42b22c8a6..0b8f239b1 100644 --- a/packages/extension/src/ui/utils.ts +++ b/packages/extension/src/ui/utils.ts @@ -1,3 +1,5 @@ +import { makeCounter } from '@ocap/utils'; + /** * Validates a bundle URL. * @@ -16,3 +18,6 @@ export function isValidBundleUrl(url?: string): boolean { return false; } } + +const idCounter = makeCounter(); +export const nextMessageId = (): string => `ui:${idCounter()}`; diff --git a/packages/extension/test/e2e/vat-manager.test.ts b/packages/extension/test/e2e/vat-manager.test.ts index aac6e5685..e816ffd17 100644 --- a/packages/extension/test/e2e/vat-manager.test.ts +++ b/packages/extension/test/e2e/vat-manager.test.ts @@ -195,7 +195,7 @@ test.describe('Vat Manager', () => { await popupPage.click('button:text("Send")'); await expect(messageOutput).toContainText('"method": "deliver",'); await expect(messageOutput).toContainText('"bringOutYourDead"'); - await expect(messageOutput).toContainText('"result":null}'); + await expect(messageOutput).toContainText('"result":[[],[]]}'); }); test('should reload kernel state and load default vats', async () => { diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index c433659d9..6ca98d1ae 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -46,6 +46,21 @@ "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest --config vitest.config.ts" }, + "dependencies": { + "@agoric/store": "0.9.3-u19.0", + "@endo/eventual-send": "^1.3.1", + "@endo/exo": "^1.5.9", + "@endo/marshal": "^1.6.4", + "@endo/patterns": "^1.5.0", + "@endo/promise-kit": "^1.1.10", + "@metamask/utils": "^11.4.0", + "@ocap/kernel": "workspace:^", + "@ocap/nodejs": "workspace:^", + "@ocap/shims": "workspace:^", + "@ocap/store": "workspace:^", + "@ocap/streams": "workspace:^", + "@ocap/utils": "workspace:^" + }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@metamask/auto-changelog": "^5.0.1", @@ -68,7 +83,6 @@ "eslint-plugin-n": "^17.17.0", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", - "fast-deep-equal": "^3.1.3", "jsdom": "^26.0.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", @@ -80,19 +94,5 @@ }, "engines": { "node": "^20 || >=22" - }, - "dependencies": { - "@agoric/store": "0.9.3-u19.0", - "@endo/eventual-send": "^1.3.1", - "@endo/exo": "^1.5.9", - "@endo/marshal": "^1.6.4", - "@endo/patterns": "^1.5.0", - "@endo/promise-kit": "^1.1.10", - "@ocap/kernel": "workspace:^", - "@ocap/nodejs": "workspace:^", - "@ocap/shims": "workspace:^", - "@ocap/store": "workspace:^", - "@ocap/streams": "workspace:^", - "@ocap/utils": "workspace:^" } } diff --git a/packages/kernel-test/src/supervisor.test.ts b/packages/kernel-test/src/supervisor.test.ts index 569b623ae..664ccc1a4 100644 --- a/packages/kernel-test/src/supervisor.test.ts +++ b/packages/kernel-test/src/supervisor.test.ts @@ -1,6 +1,8 @@ import '@ocap/shims/endoify'; -import type { VatCommand, VatConfig, VatCommandReply } from '@ocap/kernel'; -import { VatSupervisor, VatCommandMethod, kser } from '@ocap/kernel'; +import type { VatConfig } from '@ocap/kernel'; +import { VatSupervisor, kser } from '@ocap/kernel'; +import { delay } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; import { readFile } from 'fs/promises'; import { join } from 'path'; import { describe, it, expect } from 'vitest'; @@ -9,21 +11,21 @@ import { getBundleSpec } from './utils.ts'; import { TestDuplexStream } from '../../streams/test/stream-mocks.ts'; const makeVatSupervisor = async ({ - handleWrite = () => undefined, + dispatch = () => undefined, vatPowers, }: { - handleWrite?: (input: unknown) => void | Promise; + dispatch?: (input: unknown) => void | Promise; vatPowers?: Record; }) => { - const commandStream = await TestDuplexStream.make< - VatCommand, - VatCommandReply - >(handleWrite); + const kernelStream = await TestDuplexStream.make< + JsonRpcMessage, + JsonRpcMessage + >(dispatch); return { supervisor: new VatSupervisor({ id: 'test-id', - commandStream, + kernelStream, vatPowers, // eslint-disable-next-line n/no-unsupported-features/node-builtins fetchBlob: async (url: string): Promise => { @@ -40,7 +42,7 @@ const makeVatSupervisor = async ({ } as Response; }, }), - stream: commandStream, + stream: kernelStream, }; }; @@ -51,31 +53,36 @@ describe('VatSupervisor', () => { const vatPowers = { foo: async (value: string) => (localValue = value), }; - const { supervisor } = await makeVatSupervisor({ vatPowers }); + const { stream } = await makeVatSupervisor({ + vatPowers, + }); const vatConfig: VatConfig = { bundleSpec: getBundleSpec('powers-vat'), parameters: { bar: 'buzz' }, }; - await supervisor.handleMessage({ - id: 'test-id', - payload: { - method: VatCommandMethod.initVat, - params: { - vatConfig, - state: new Map(), - }, + await stream.receiveInput({ + id: 'test-id-1', + method: 'initVat', + params: { + vatConfig, + state: [], }, + jsonrpc: '2.0', }); - await supervisor.handleMessage({ - id: 'test-id', - payload: { - method: VatCommandMethod.deliver, - params: ['message', 'o+0', { methargs: kser(['fizz', []]) }], - }, + await stream.receiveInput({ + id: 'test-id-2', + method: 'deliver', + params: [ + 'message', + 'o+0', + { methargs: kser(['fizz', []]), result: null }, + ], + jsonrpc: '2.0', }); + await delay(100); expect(localValue).toBe('buzz'); }); diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 72538b75d..c8e0ed6f8 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -1,12 +1,9 @@ // eslint-disable-next-line spaced-comment /// +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { Kernel, kunser } from '@ocap/kernel'; -import type { - ClusterConfig, - KernelCommand, - KernelCommandReply, -} from '@ocap/kernel'; +import type { ClusterConfig } from '@ocap/kernel'; import { NodejsVatWorkerManager } from '@ocap/nodejs'; import type { KernelDatabase } from '@ocap/store'; import { NodeWorkerDuplexStream } from '@ocap/streams'; @@ -81,8 +78,8 @@ export async function makeKernel( ): Promise { const kernelPort: NodeMessagePort = new NodeMessageChannel().port1; const nodeStream = new NodeWorkerDuplexStream< - KernelCommand, - KernelCommandReply + JsonRpcRequest, + JsonRpcResponse >(kernelPort); const vatWorkerClient = new NodejsVatWorkerManager({}); const kernel = await Kernel.make( diff --git a/packages/kernel-test/src/vatstore.test.ts b/packages/kernel-test/src/vatstore.test.ts index 2ea56b7f0..b3bfb2718 100644 --- a/packages/kernel-test/src/vatstore.test.ts +++ b/packages/kernel-test/src/vatstore.test.ts @@ -1,8 +1,7 @@ import '@ocap/shims/endoify'; -import type { ClusterConfig, VatCheckpoint } from '@ocap/kernel'; -import type { VatStore } from '@ocap/store'; +import type { ClusterConfig } from '@ocap/kernel'; +import type { VatStore, VatCheckpoint } from '@ocap/store'; import { makeSQLKernelDatabase } from '@ocap/store/sqlite/nodejs'; -import deepEqual from 'fast-deep-equal'; import { describe, vi, expect, it } from 'vitest'; import { getBundleSpec, makeKernel, runTestVats } from './utils.ts'; @@ -32,14 +31,14 @@ const makeTestSubcluster = (): ClusterConfig => ({ }, }); -const emptyMap = new Map(); -const emptySet = new Set(); +const emptySets: [string, string][] = []; +const emptyDeletes: string[] = []; // prettier-ignore -const referenceKVUpdates = [ +const referenceKVUpdates: VatCheckpoint[] = [ [ // initVat initializes built-in tables and empty baggage - new Map([ + [ ['baggageID', 'o+d6/1'], ['idCounters', '{"exportID":10,"collectionID":5,"promiseID":5}'], ['kindIDID', '1'], @@ -61,50 +60,50 @@ const referenceKVUpdates = [ ['vom.rc.o+d6/4', '1'], ['watchedPromiseTableID', 'o+d6/4'], ['watcherTableID', 'o+d6/3'], - ]), - emptySet, + ], + emptyDeletes, ], // execution of 'bootstrap' initializes baggage, setting "thing" to 1 and // "goAway" to the string "now you see me", (and thus the baggage entry count // to 2). [ - new Map([ + [ ['idCounters', '{"exportID":10,"collectionID":5,"promiseID":7}'], ['vc.1.sgoAway', '{"body":"#\\"now you see me\\"","slots":[]}'], ['vc.1.sthing', '{"body":"#1","slots":[]}'], ['vc.1.|entryCount', '2'], - ]), - emptySet, + ], + emptyDeletes, ], // first 'bump' (from Bob) increments "thing" to 2 [ - new Map([ + [ ['vc.1.sthing', '{"body":"#2","slots":[]}'], - ]), - emptySet, + ], + emptyDeletes, ], // notification of 'go' result from Bob changes nothing - [emptyMap, emptySet], + [emptySets, emptyDeletes], // second 'bump' (from Carol) increments "thing" to 3 [ - new Map([ + [ ['vc.1.sthing', '{"body":"#3","slots":[]}'], - ]), - emptySet, + ], + emptyDeletes, ], // notification of 'go' result from Carol allows 'bootstrap' method to // complete, deleting "goAway" from baggage and dropping the baggage entry // count to 1. Sending 'loopback' consumes a promise ID. [ - new Map([ + [ ['idCounters', '{"exportID":10,"collectionID":5,"promiseID":8}'], ['vc.1.|entryCount', '1'], - ]), - new Set(['vc.1.sgoAway']), + ], + ['vc.1.sgoAway'], ], // notification of 'loopback' result changes nothing - [emptyMap, emptySet], -] + [emptySets, emptyDeletes], +]; describe('exercise vatstore', async () => { it('exercise vatstore', async () => { @@ -119,7 +118,7 @@ describe('exercise vatstore', async () => { if (vatID === 'v1') { const origUpdateKVData = result.updateKVData; vi.spyOn(result, 'updateKVData').mockImplementation( - (sets: Map, deletes: Set): void => { + (sets: [string, string][], deletes: string[]): void => { kvUpdates.push([sets, deletes]); origUpdateKVData(sets, deletes); }, @@ -143,6 +142,27 @@ describe('exercise vatstore', async () => { ); expect(vsKv.get('vc.1.sthing')).toBe('{"body":"#3","slots":[]}'); expect(vsKv.get('vc.1.|entryCount')).toBe('1'); - expect(deepEqual(kvUpdates, referenceKVUpdates)).toBe(true); + expect(normalize(kvUpdates)).toStrictEqual(normalize(referenceKVUpdates)); }, 30000); }); + +/** + * Normalize an array of vat checkpoints to a comparable format. + * + * @param checkpoints - The vat checkpoints to normalize. + * @returns The normalized vat checkpoints. + */ +function normalize( + checkpoints: VatCheckpoint[], +): [Record, string[]][] { + return checkpoints.map((checkpoint) => { + const [sets, deletes] = checkpoint; + return [ + sets.reduce>((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {}), + deletes.sort(), + ]; + }); +} diff --git a/packages/kernel/package.json b/packages/kernel/package.json index 588522a98..75a786bb7 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -59,6 +59,7 @@ "@endo/marshal": "^1.6.4", "@endo/pass-style": "^1.5.0", "@endo/promise-kit": "^1.1.10", + "@metamask/rpc-errors": "^7.0.2", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.0", "@ocap/errors": "workspace:^", diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts index 2cdfb7e49..8601f738d 100644 --- a/packages/kernel/src/Kernel.test.ts +++ b/packages/kernel/src/Kernel.test.ts @@ -1,17 +1,13 @@ +import type { JsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; import { VatNotFoundError } from '@ocap/errors'; import type { KernelDatabase } from '@ocap/store'; import type { DuplexStream } from '@ocap/streams'; import { TestDuplexStream } from '@ocap/test-utils/streams'; +import type { JsonRpcMessage } from '@ocap/utils'; import type { Mocked, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Kernel } from './Kernel.ts'; -import type { - KernelCommand, - KernelCommandReply, - VatCommand, - VatCommandReply, -} from './messages/index.ts'; import type { VatId, VatConfig, @@ -37,7 +33,7 @@ const makeMockClusterConfig = (): ClusterConfig => ({ }); describe('Kernel', () => { - let mockStream: DuplexStream; + let mockStream: DuplexStream; let mockWorkerService: VatWorkerManager; let launchWorkerMock: MockInstance; let terminateWorkerMock: MockInstance; @@ -47,13 +43,13 @@ describe('Kernel', () => { beforeEach(async () => { const dummyDispatch = vi.fn(); - mockStream = await TestDuplexStream.make( + mockStream = await TestDuplexStream.make( dummyDispatch, ); mockWorkerService = { launch: async () => - ({}) as unknown as DuplexStream, + ({}) as unknown as DuplexStream, terminate: async () => undefined, terminateAll: async () => undefined, } as unknown as VatWorkerManager; @@ -61,7 +57,7 @@ describe('Kernel', () => { launchWorkerMock = vi .spyOn(mockWorkerService, 'launch') .mockResolvedValue( - {} as unknown as DuplexStream, + {} as unknown as DuplexStream, ); terminateWorkerMock = vi .spyOn(mockWorkerService, 'terminate') @@ -303,10 +299,10 @@ describe('Kernel', () => { ); await kernel.launchVat(makeMockVatConfig()); vatHandles[0]?.sendVatCommand.mockResolvedValueOnce('test'); - const result = await kernel.sendVatCommand( - 'v1', - 'test' as unknown as VatCommand['payload'], - ); + const result = await kernel.sendVatCommand('v1', { + method: 'ping', + params: [], + }); expect(result).toBe('test'); }); @@ -318,7 +314,10 @@ describe('Kernel', () => { ); const nonExistentVatId: VatId = 'v9'; await expect(async () => - kernel.sendVatCommand(nonExistentVatId, {} as VatCommand['payload']), + kernel.sendVatCommand(nonExistentVatId, { + method: 'ping', + params: [], + }), ).rejects.toThrow(VatNotFoundError); }); @@ -331,7 +330,10 @@ describe('Kernel', () => { await kernel.launchVat(makeMockVatConfig()); vatHandles[0]?.sendVatCommand.mockRejectedValueOnce('error'); await expect(async () => - kernel.sendVatCommand('v1', {} as VatCommand['payload']), + kernel.sendVatCommand('v1', { + method: 'ping', + params: [], + }), ).rejects.toThrow('error'); }); }); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 941e75343..af71f04db 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -1,22 +1,21 @@ import type { CapData } from '@endo/marshal'; +import { serializeError } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { StreamReadError, VatAlreadyExistsError, VatNotFoundError, } from '@ocap/errors'; +import { RpcService } from '@ocap/rpc-methods'; +import type { ExtractParams, ExtractResult } from '@ocap/rpc-methods'; import type { KernelDatabase } from '@ocap/store'; import type { DuplexStream } from '@ocap/streams'; import { Logger } from '@ocap/utils'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; -import { isKernelCommand, KernelCommandMethod } from './messages/index.ts'; -import type { - KernelCommand, - KernelCommandReply, - VatCommand, - VatCommandReturnType, -} from './messages/index.ts'; +import { kernelHandlers } from './rpc/index.ts'; +import type { VatMethod, vatMethodSpecs } from './rpc/index.ts'; import type { SlotValue } from './services/kernel-marshal.ts'; import { kslot } from './services/kernel-marshal.ts'; import { makeKernelStore } from './store/index.ts'; @@ -34,7 +33,9 @@ import { VatHandle } from './VatHandle.ts'; export class Kernel { /** Command channel from the controlling console/browser extension/test driver */ - readonly #commandStream: DuplexStream; + readonly #commandStream: DuplexStream; + + readonly #rpcService: RpcService; /** Currently running vats, by ID */ readonly #vats: Map; @@ -69,7 +70,7 @@ export class Kernel { */ // eslint-disable-next-line no-restricted-syntax private constructor( - commandStream: DuplexStream, + commandStream: DuplexStream, vatWorkerService: VatWorkerManager, kernelDatabase: KernelDatabase, options: { @@ -79,6 +80,7 @@ export class Kernel { ) { this.#mostRecentSubcluster = null; this.#commandStream = commandStream; + this.#rpcService = new RpcService(kernelHandlers, {}); this.#vats = new Map(); this.#vatWorkerService = vatWorkerService; this.#logger = options.logger ?? new Logger('[ocap kernel]'); @@ -106,7 +108,7 @@ export class Kernel { * @returns A promise for the new kernel instance. */ static async make( - commandStream: DuplexStream, + commandStream: DuplexStream, vatWorkerService: VatWorkerManager, kernelDatabase: KernelDatabase, options: { @@ -129,10 +131,12 @@ export class Kernel { * and then begin processing the run queue. */ async #init(): Promise { - this.#receiveCommandMessages().catch((error) => { - this.#logger.error('Stream read error:', error); - throw new StreamReadError({ kernelId: 'kernel' }, error); - }); + this.#commandStream + .drain(this.#handleCommandMessage.bind(this)) + .catch((error) => { + this.#logger.error('Stream read error:', error); + throw new StreamReadError({ kernelId: 'kernel' }, error); + }); const starts: Promise[] = []; for (const { vatID, vatConfig } of this.#kernelStore.getAllVatRecords()) { starts.push(this.#runVat(vatID, vatConfig)); @@ -147,46 +151,32 @@ export class Kernel { } /** - * Process messages received over the command channel. + * Handle messages received over the command channel. * - * Note that all the messages currently handled here are for interactive - * testing support, not for normal operation or control of the kernel. We - * expect that in the fullness of time the command protocol will expand to - * include actual operational functions, while the things that are mere test - * scaffolding will be removed. + * @param message - The message to handle. */ - async #receiveCommandMessages(): Promise { - for await (const message of this.#commandStream) { - if (!isKernelCommand(message)) { - this.#logger.error('Received unexpected message', message); - continue; - } - - const { method, params } = message; - - switch (method) { - case KernelCommandMethod.ping: - await this.#replyToCommand({ method, params: 'pong' }); - break; - default: - console.error( - 'kernel worker received unexpected command', - // @ts-expect-error Compile-time exhaustiveness check - { method: method.valueOf(), params }, - ); - } + async #handleCommandMessage(message: JsonRpcRequest): Promise { + try { + this.#rpcService.assertHasMethod(message.method); + const result = await this.#rpcService.execute( + message.method, + message.params, + ); + await this.#commandStream.write({ + id: message.id, + jsonrpc: '2.0', + result, + }); + } catch (error) { + this.#logger.error('Error executing command', error); + await this.#commandStream.write({ + id: message.id, + jsonrpc: '2.0', + error: serializeError(error), + }); } } - /** - * Transmit the reply to a command back to its requestor. - * - * @param message - The reply message to send. - */ - async #replyToCommand(message: KernelCommandReply): Promise { - await this.#commandStream.write(message); - } - /** * Launches a new vat. * @@ -214,11 +204,11 @@ export class Kernel { if (this.#vats.has(vatId)) { throw new VatAlreadyExistsError(vatId); } - const commandStream = await this.#vatWorkerService.launch(vatId, vatConfig); + const vatStream = await this.#vatWorkerService.launch(vatId, vatConfig); const vat = await VatHandle.make({ vatId, vatConfig, - vatStream: commandStream, + vatStream, kernelStore: this.#kernelStore, kernelQueue: this.#kernelQueue, }); @@ -359,14 +349,22 @@ export class Kernel { * * @param id - The id of the vat to send the command to. * @param command - The command to send. + * @param command.method - The method to call. + * @param command.params - The parameters to pass to the method. * @returns A promise that resolves the response to the command. */ - async sendVatCommand( + async sendVatCommand( id: VatId, - command: Extract, - ): Promise { + { + method, + params, + }: { + method: Method; + params: ExtractParams; + }, + ): Promise> { const vat = this.#getVat(id); - return vat.sendVatCommand(command); + return vat.sendVatCommand({ method, params }); } /** diff --git a/packages/kernel/src/KernelQueue.test.ts b/packages/kernel/src/KernelQueue.test.ts index 7171d11ae..15a5ac076 100644 --- a/packages/kernel/src/KernelQueue.test.ts +++ b/packages/kernel/src/KernelQueue.test.ts @@ -1,4 +1,4 @@ -import type { Message, VatOneResolution } from '@agoric/swingset-liveslots'; +import type { VatOneResolution } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; import { makePromiseKit } from '@endo/promise-kit'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -8,6 +8,7 @@ import { KernelQueue } from './KernelQueue.ts'; import type { KernelStore } from './store/index.ts'; import type { KRef, + Message, RunQueueItem, RunQueueItemNotify, RunQueueItemSend, diff --git a/packages/kernel/src/KernelQueue.ts b/packages/kernel/src/KernelQueue.ts index 8e5b7fe41..15c7b7c21 100644 --- a/packages/kernel/src/KernelQueue.ts +++ b/packages/kernel/src/KernelQueue.ts @@ -1,13 +1,14 @@ -import type { Message, VatOneResolution } from '@agoric/swingset-liveslots'; +import type { VatOneResolution } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; import { makePromiseKit } from '@endo/promise-kit'; import { processGCActionSet } from './services/garbage-collection.ts'; import { kser } from './services/kernel-marshal.ts'; -import type { KernelStore } from './store'; +import type { KernelStore } from './store/index.ts'; import { insistVatId } from './types.ts'; import type { KRef, + Message, RunQueueItem, RunQueueItemNotify, RunQueueItemSend, diff --git a/packages/kernel/src/KernelRouter.test.ts b/packages/kernel/src/KernelRouter.test.ts index 2200c516c..3216dec40 100644 --- a/packages/kernel/src/KernelRouter.test.ts +++ b/packages/kernel/src/KernelRouter.test.ts @@ -1,4 +1,3 @@ -import type { Message as SwingsetMessage } from '@agoric/swingset-liveslots'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MockInstance } from 'vitest'; @@ -6,6 +5,7 @@ import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import type { KernelStore } from './store/index.ts'; import type { + Message as SwingsetMessage, RunQueueItem, RunQueueItemSend, RunQueueItemNotify, diff --git a/packages/kernel/src/VatHandle.test.ts b/packages/kernel/src/VatHandle.test.ts index 4b4cfd6cb..52e8b492c 100644 --- a/packages/kernel/src/VatHandle.test.ts +++ b/packages/kernel/src/VatHandle.test.ts @@ -1,13 +1,12 @@ +import type { Json } from '@metamask/utils'; import { delay } from '@ocap/test-utils'; import { TestDuplexStream } from '@ocap/test-utils/streams'; -import type { Logger } from '@ocap/utils'; -import { makeLogger } from '@ocap/utils'; +import type { JsonRpcMessage, Logger } from '@ocap/utils'; +import { isJsonRpcMessage, makeLogger } from '@ocap/utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MockInstance } from 'vitest'; import type { KernelQueue } from './KernelQueue.ts'; -import { isVatCommandReply, VatCommandMethod } from './messages/index.ts'; -import type { VatCommand, VatCommandReply } from './messages/index.ts'; import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; import { VatHandle } from './VatHandle.ts'; @@ -23,28 +22,32 @@ vi.mock('@endo/eventual-send', () => ({ let mockKernelStore: KernelStore; -const makeVat = async ( - logger?: Logger, -): Promise<{ +const makeVat = async ({ + logger, + dispatch, +}: { + logger?: Logger; + dispatch?: (input: unknown) => void | Promise; +} = {}): Promise<{ vat: VatHandle; - stream: TestDuplexStream; + stream: TestDuplexStream; }> => { - const commandStream = await TestDuplexStream.make< - VatCommandReply, - VatCommand - >(() => undefined, { - validateInput: isVatCommandReply, - }); + const vatStream = await TestDuplexStream.make( + dispatch ?? (() => undefined), + { + validateInput: isJsonRpcMessage, + }, + ); return { vat: await VatHandle.make({ kernelQueue: null as unknown as KernelQueue, kernelStore: mockKernelStore, vatId: 'v0', vatConfig: { sourceSpec: 'not-really-there.js' }, - vatStream: commandStream, + vatStream, logger, }), - stream: commandStream, + stream: vatStream, }; }; @@ -64,9 +67,9 @@ describe('VatHandle', () => { expect(sendVatCommandMock).toHaveBeenCalledTimes(1); expect(sendVatCommandMock).toHaveBeenCalledWith({ - method: VatCommandMethod.initVat, + method: 'initVat' as const, params: { - state: new Map(), + state: [], vatConfig: { sourceSpec: 'not-really-there.js', }, @@ -76,7 +79,7 @@ describe('VatHandle', () => { it('throws if the stream throws', async () => { const logger = makeLogger(`[vat v0]`); - const { stream } = await makeVat(logger); + const { stream } = await makeVat({ logger }); const logErrorSpy = vi.spyOn(logger, 'error'); await stream.receiveInput(NaN); await delay(10); @@ -89,48 +92,26 @@ describe('VatHandle', () => { describe('sendVatCommand', () => { it('sends a message and resolves the promise', async () => { - const { vat } = await makeVat(); + const dispatch = vi.fn(); + const { vat, stream } = await makeVat({ dispatch }); const mockMessage = { - method: VatCommandMethod.ping, - params: [], - } as VatCommand['payload']; + method: 'ping' as const, + params: [] as Json[], + }; const sendVatCommandPromise = vat.sendVatCommand(mockMessage); + await delay(10); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining(mockMessage), + ); - // Simulate response using handleMessage instead of direct resolver access - await vat.handleMessage({ + await stream.receiveInput({ id: 'v0:1', - payload: { - method: VatCommandMethod.ping, - params: 'test-response', - }, - }); - - const result = await sendVatCommandPromise; - expect(result).toBe('test-response'); - }); - }); - - describe('handleMessage', () => { - it('resolves the payload when the message id exists', async () => { - const { vat } = await makeVat(); - const mockMessageId = 'v0:1'; - const mockPayload: VatCommandReply['payload'] = { - method: VatCommandMethod.ping, - params: 'test', - }; - - // Create a pending message first - const messagePromise = vat.sendVatCommand({ - method: VatCommandMethod.ping, - params: [], + result: 'test-response', + jsonrpc: '2.0', }); - // Handle the response - await vat.handleMessage({ id: mockMessageId, payload: mockPayload }); - - const result = await messagePromise; - expect(result).toBe('test'); + expect(await sendVatCommandPromise).toBe('test-response'); }); }); @@ -140,7 +121,7 @@ describe('VatHandle', () => { // Create a pending message that should be rejected on terminate const messagePromise = vat.sendVatCommand({ - method: VatCommandMethod.ping, + method: 'ping' as const, params: [], }); diff --git a/packages/kernel/src/VatHandle.ts b/packages/kernel/src/VatHandle.ts index 966307fa9..2aab110fb 100644 --- a/packages/kernel/src/VatHandle.ts +++ b/packages/kernel/src/VatHandle.ts @@ -1,31 +1,29 @@ import type { - Message, - VatSyscallObject, VatOneResolution, + VatSyscallObject, } from '@agoric/swingset-liveslots'; -import { makePromiseKit } from '@endo/promise-kit'; +import { serializeError } from '@metamask/rpc-errors'; +import { isJsonRpcRequest, isJsonRpcResponse } from '@metamask/utils'; import { VatDeletedError, StreamReadError } from '@ocap/errors'; -import type { VatStore } from '@ocap/store'; +import { RpcClient, RpcService } from '@ocap/rpc-methods'; +import type { ExtractParams, ExtractResult } from '@ocap/rpc-methods'; +import type { VatStore, VatCheckpoint } from '@ocap/store'; import type { DuplexStream } from '@ocap/streams'; -import type { PromiseCallbacks } from '@ocap/utils'; -import { Logger, makeCounter } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; +import { Logger } from '@ocap/utils'; import type { KernelQueue } from './KernelQueue.ts'; -import { VatCommandMethod } from './messages/index.ts'; -import type { - VatCommandReply, - VatCommand, - VatCommandReturnType, -} from './messages/index.ts'; +import { vatMethodSpecs, vatSyscallHandlers } from './rpc/index.ts'; +import type { VatMethod } from './rpc/index.ts'; import { kser } from './services/kernel-marshal.ts'; import type { KernelStore } from './store/index.ts'; -import type { VatId, VatConfig, VRef } from './types.ts'; +import type { Message, VatId, VatConfig, VRef } from './types.ts'; import { VatSyscall } from './VatSyscall.ts'; type VatConstructorProps = { vatId: VatId; vatConfig: VatConfig; - vatStream: DuplexStream; + vatStream: DuplexStream; kernelStore: KernelStore; kernelQueue: KernelQueue; logger?: Logger | undefined; @@ -36,7 +34,7 @@ export class VatHandle { readonly vatId: VatId; /** Communications channel to and from the vat itself */ - readonly #vatStream: DuplexStream; + readonly #vatStream: DuplexStream; /** The vat's configuration */ readonly config: VatConfig; @@ -44,25 +42,22 @@ export class VatHandle { /** Logger for outputting messages (such as errors) to the console */ readonly #logger: Logger; - /** Counter for associating messages to the vat with their replies */ - readonly #messageCounter: () => number; - /** Storage holding the kernel's persistent state */ readonly #kernelStore: KernelStore; /** Storage holding this vat's persistent state */ readonly #vatStore: VatStore; - /** Callbacks to handle message replies, indexed by message id */ - readonly #unresolvedMessages: Map = - new Map(); - /** The vat's syscall */ readonly #vatSyscall: VatSyscall; /** The kernel's queue */ readonly #kernelQueue: KernelQueue; + readonly #rpcClient: RpcClient; + + readonly #rpcService: RpcService; + /** * Construct a new VatHandle instance. * @@ -86,7 +81,6 @@ export class VatHandle { this.vatId = vatId; this.config = vatConfig; this.#logger = logger ?? new Logger(`[vat ${vatId}]`); - this.#messageCounter = makeCounter(); this.#vatStream = vatStream; this.#kernelStore = kernelStore; this.#vatStore = kernelStore.makeVatStore(vatId); @@ -97,6 +91,20 @@ export class VatHandle { kernelStore, logger: this.#logger.subLogger({ tags: ['syscall'] }), }); + + this.#rpcClient = new RpcClient( + vatMethodSpecs, + async (request) => { + await this.#vatStream.write(request); + }, + `${this.vatId}:`, + ); + this.#rpcService = new RpcService(vatSyscallHandlers, { + handleSyscall: async (params) => { + await this.#vatSyscall.handleSyscall(params as VatSyscallObject); + return ['ok', null]; // XXX TODO: Return actual results from syscalls + }, + }); } /** @@ -123,7 +131,7 @@ export class VatHandle { * @returns A promise that resolves when the vat is initialized. */ async #init(): Promise { - Promise.all([this.#vatStream.drain(this.handleMessage.bind(this))]).catch( + Promise.all([this.#vatStream.drain(this.#handleMessage.bind(this))]).catch( async (error) => { this.#logger.error(`Unexpected read error`, error); await this.terminate( @@ -134,8 +142,11 @@ export class VatHandle { ); await this.sendVatCommand({ - method: VatCommandMethod.initVat, - params: { vatConfig: this.config, state: this.#vatStore.getKVData() }, + method: 'initVat', + params: { + vatConfig: this.config, + state: this.#vatStore.getKVData(), + }, }); } @@ -146,30 +157,27 @@ export class VatHandle { * @param message.id - The id of the message. * @param message.payload - The payload (i.e., the message itself) to handle. */ - async handleMessage({ id, payload }: VatCommandReply): Promise { - // Syscalls are currently the only messages that actually originate from the - // vat. All others will be replies to messages originally sent by the kernel TO the - // vat. - if (payload.method === VatCommandMethod.syscall) { - await this.#vatSyscall.handleSyscall(payload.params as VatSyscallObject); - } else { - let result; - if ( - payload.method === VatCommandMethod.deliver || - payload.method === VatCommandMethod.initVat - ) { - result = null; - const [sets, deletes] = payload.params; - this.#vatStore.updateKVData(sets, deletes); - } else { - result = payload.params; - } - const promiseCallbacks = this.#unresolvedMessages.get(id); - if (promiseCallbacks === undefined) { - this.#logger.error(`No unresolved message with id "${id}".`); - } else { - this.#unresolvedMessages.delete(id); - promiseCallbacks.resolve(result); + async #handleMessage(message: JsonRpcMessage): Promise { + if (isJsonRpcResponse(message)) { + this.#rpcClient.handleResponse(message.id as string, message); + } else if (isJsonRpcRequest(message)) { + try { + this.#rpcService.assertHasMethod(message.method); + const result = await this.#rpcService.execute( + message.method, + message.params, + ); + await this.#vatStream.write({ + id: message.id, + result, + jsonrpc: '2.0', + }); + } catch (error) { + await this.#vatStream.write({ + id: message.id, + error: serializeError(error), + jsonrpc: '2.0', + }); } } } @@ -182,7 +190,7 @@ export class VatHandle { */ async deliverMessage(target: VRef, message: Message): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['message', target, message], }); } @@ -194,7 +202,7 @@ export class VatHandle { */ async deliverNotify(resolutions: VatOneResolution[]): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['notify', resolutions], }); } @@ -206,7 +214,7 @@ export class VatHandle { */ async deliverDropExports(vrefs: VRef[]): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['dropExports', vrefs], }); } @@ -218,7 +226,7 @@ export class VatHandle { */ async deliverRetireExports(vrefs: VRef[]): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['retireExports', vrefs], }); } @@ -230,7 +238,7 @@ export class VatHandle { */ async deliverRetireImports(vrefs: VRef[]): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['retireImports', vrefs], }); } @@ -240,7 +248,7 @@ export class VatHandle { */ async deliverBringOutYourDead(): Promise { await this.sendVatCommand({ - method: VatCommandMethod.deliver, + method: 'deliver', params: ['bringOutYourDead'], }); } @@ -262,13 +270,7 @@ export class VatHandle { this.#kernelQueue.resolvePromises(this.vatId, [[kpid, true, failure]]); } - // Reject promises for results of method invocations from the kernel - for (const [messageId, promiseCallback] of this.#unresolvedMessages) { - promiseCallback?.reject(error ?? new VatDeletedError(this.vatId)); - this.#unresolvedMessages.delete(messageId); - } - - // Expunge this vat's persistent state + this.#rpcClient.rejectAll(error ?? new VatDeletedError(this.vatId)); this.#kernelStore.deleteVat(this.vatId); } } @@ -276,25 +278,24 @@ export class VatHandle { /** * Send a command into the vat. * - * @param payload - The command to send. + * @param payload - The payload of the command. + * @param payload.method - The method to call. + * @param payload.params - The parameters to pass to the method. * @returns A promise that resolves the response to the command. */ - async sendVatCommand( - payload: Extract, - ): Promise { - const { promise, reject, resolve } = makePromiseKit(); - const messageId = this.#nextMessageId(); - this.#unresolvedMessages.set(messageId, { reject, resolve }); - await this.#vatStream.write({ id: messageId, payload }); - return promise as Promise; + async sendVatCommand({ + method, + params, + }: { + method: Method; + params: ExtractParams; + }): Promise> { + const result = await this.#rpcClient.call(method, params); + if (method === 'deliver' || method === 'initVat') { + // TypeScript fails to narrow the result type on its own + const [sets, deletes] = result as VatCheckpoint; + this.#vatStore.updateKVData(sets, deletes); + } + return result; } - - /** - * Gets the next message ID. - * - * @returns The message ID. - */ - readonly #nextMessageId = (): VatCommand['id'] => { - return `${this.vatId}:${this.#messageCounter()}`; - }; } diff --git a/packages/kernel/src/VatSupervisor.test.ts b/packages/kernel/src/VatSupervisor.test.ts index 6a9b1234e..f0bfc0ab4 100644 --- a/packages/kernel/src/VatSupervisor.test.ts +++ b/packages/kernel/src/VatSupervisor.test.ts @@ -1,10 +1,10 @@ +import { rpcErrors } from '@metamask/rpc-errors'; import '@ocap/test-utils'; import { TestDuplexStream } from '@ocap/test-utils/streams'; -import { delay } from '@ocap/utils'; +import { delay, isJsonRpcMessage } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; import { describe, it, expect, vi } from 'vitest'; -import { VatCommandMethod } from './messages/index.ts'; -import type { VatCommand, VatCommandReply } from './messages/index.ts'; import { VatSupervisor } from './VatSupervisor.ts'; vi.mock('./syscall.ts', () => ({ @@ -22,23 +22,23 @@ vi.mock('@agoric/swingset-liveslots', () => ({ })); const makeVatSupervisor = async ( - handleWrite?: (input: unknown) => void | Promise, + dispatch?: (input: unknown) => void | Promise, vatPowers?: Record, ): Promise<{ supervisor: VatSupervisor; - stream: TestDuplexStream; + stream: TestDuplexStream; }> => { - const commandStream = await TestDuplexStream.make< - VatCommand, - VatCommandReply - >(handleWrite ?? (() => undefined)); + const kernelStream = await TestDuplexStream.make< + JsonRpcMessage, + JsonRpcMessage + >(dispatch ?? (() => undefined), { validateInput: isJsonRpcMessage }); return { supervisor: new VatSupervisor({ id: 'test-id', - commandStream, + kernelStream, vatPowers: vatPowers ?? {}, }), - stream: commandStream, + stream: kernelStream, }; }; @@ -63,47 +63,46 @@ describe('VatSupervisor', () => { }); describe('handleMessage', () => { - it('throws if receiving an unexpected message', async () => { - const { supervisor, stream } = await makeVatSupervisor(); + it('responds with an error for unknown methods', async () => { + const dispatch = vi.fn(); + const { stream } = await makeVatSupervisor(dispatch); - const consoleErrorSpy = vi.spyOn(console, 'error'); await stream.receiveInput({ - channel: 'command', - payload: { method: 'test' }, + id: 'v0:0', + method: 'bogus', + params: [], + jsonrpc: '2.0', }); await delay(10); - expect(consoleErrorSpy).toHaveBeenCalledOnce(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Unexpected read error from VatSupervisor "${supervisor.id}"`, - new Error(`VatSupervisor received unexpected command method: "test"`), + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'v0:0', + error: expect.objectContaining({ + code: rpcErrors.methodNotFound().code, + }), + }), ); }); - it('handles Ping messages', async () => { - const { supervisor } = await makeVatSupervisor(); - const replySpy = vi.spyOn(supervisor, 'replyToMessage'); + it('handles "ping" requests', async () => { + const dispatch = vi.fn(); + const { stream } = await makeVatSupervisor(dispatch); - await supervisor.handleMessage({ + await stream.receiveInput({ id: 'v0:0', - payload: { method: VatCommandMethod.ping, params: [] }, - }); - - expect(replySpy).toHaveBeenCalledWith('v0:0', { - method: VatCommandMethod.ping, - params: 'pong', + method: 'ping', + params: [], + jsonrpc: '2.0', }); - }); - - it('handles unknown message types', async () => { - const { supervisor } = await makeVatSupervisor(); + await delay(10); - await expect( - supervisor.handleMessage({ + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ id: 'v0:0', - // @ts-expect-error - unknown message type. - payload: { method: 'UnknownType' }, + result: 'pong', }), - ).rejects.toThrow('VatSupervisor received unexpected command method:'); + ); }); }); diff --git a/packages/kernel/src/VatSupervisor.ts b/packages/kernel/src/VatSupervisor.ts index 07df48acc..c6086cb1a 100644 --- a/packages/kernel/src/VatSupervisor.ts +++ b/packages/kernel/src/VatSupervisor.ts @@ -7,20 +7,24 @@ import type { import { importBundle } from '@endo/import-bundle'; import { makeMarshal } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; +import { serializeError } from '@metamask/rpc-errors'; +import { isJsonRpcRequest, isJsonRpcResponse } from '@metamask/utils'; import { StreamReadError } from '@ocap/errors'; +import { RpcClient, RpcService } from '@ocap/rpc-methods'; +import type { VatKVStore, VatCheckpoint } from '@ocap/store'; import type { DuplexStream } from '@ocap/streams'; import { waitUntilQuiescent } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; -import type { VatCommand, VatCommandReply } from './messages/index.ts'; -import { VatCommandMethod } from './messages/index.ts'; +import { vatSyscallMethodSpecs, vatHandlers } from './rpc/index.ts'; +import type { InitVat } from './rpc/vat/initVat.ts'; import { makeGCAndFinalize } from './services/gc-finalize.ts'; import { makeDummyMeterControl } from './services/meter-control.ts'; import { makeSupervisorSyscall } from './services/syscall.ts'; import type { DispatchFn, MakeLiveSlotsFn, GCTools } from './services/types.ts'; -import type { VatKVStore } from './store/vat-kv-store.ts'; import { makeVatKVStore } from './store/vat-kv-store.ts'; -import type { VatConfig, VatId, VRef } from './types.ts'; -import { ROOT_OBJECT_VREF, isVatConfig } from './types.ts'; +import type { VatId } from './types.ts'; +import { isVatConfig, coerceVatSyscallObject } from './types.ts'; const makeLiveSlots: MakeLiveSlotsFn = localMakeLiveSlots; @@ -29,7 +33,7 @@ export type FetchBlob = (bundleURL: string) => Promise; type SupervisorConstructorProps = { id: VatId; - commandStream: DuplexStream; + kernelStream: DuplexStream; vatPowers?: Record | undefined; fetchBlob?: FetchBlob; }; @@ -43,7 +47,13 @@ export class VatSupervisor { readonly id: VatId; /** Communications channel between this vat and the kernel */ - readonly #commandStream: DuplexStream; + readonly #kernelStream: DuplexStream; + + /** RPC client for sending syscall requests to the kernel */ + readonly #rpcClient: RpcClient; + + /** RPC service for handling requests from the kernel */ + readonly #rpcService: RpcService; /** Flag that the user code has been loaded */ #loaded: boolean = false; @@ -68,26 +78,39 @@ export class VatSupervisor { * * @param params - Named constructor parameters. * @param params.id - The id of the vat being supervised. - * @param params.commandStream - Communications channel connected to the kernel. + * @param params.kernelStream - Communications channel connected to the kernel. * @param params.vatPowers - The external capabilities for this vat. * @param params.fetchBlob - Function to fetch the user code bundle for this vat. */ constructor({ id, - commandStream, + kernelStream, vatPowers, fetchBlob, }: SupervisorConstructorProps) { this.id = id; - this.#commandStream = commandStream; + this.#kernelStream = kernelStream; this.#vatPowers = vatPowers ?? {}; this.#dispatch = null; const defaultFetchBlob: FetchBlob = async (bundleURL: string) => await fetch(bundleURL); this.#fetchBlob = fetchBlob ?? defaultFetchBlob; + this.#rpcClient = new RpcClient( + vatSyscallMethodSpecs, + async (request) => { + await this.#kernelStream.write(request); + }, + `${this.id}:`, + ); + + this.#rpcService = new RpcService(vatHandlers, { + initVat: this.#initVat.bind(this), + handleDelivery: this.#deliver.bind(this), + }); + Promise.all([ - this.#commandStream.drain(this.handleMessage.bind(this)), + this.#kernelStream.drain(this.#handleMessage.bind(this)), ]).catch(async (error) => { console.error( `Unexpected read error from VatSupervisor "${this.id}"`, @@ -103,69 +126,36 @@ export class VatSupervisor { * @param error - The error to terminate the VatSupervisor with. */ async terminate(error?: Error): Promise { - await this.#commandStream.end(error); + await this.#kernelStream.end(error); } /** * Handle a message from the kernel. * * @param message - The vat message to handle. - * @param message.id - The id of the message. - * @param message.payload - The payload to handle. */ - async handleMessage({ id, payload }: VatCommand): Promise { - switch (payload.method) { - case VatCommandMethod.deliver: { - if (!this.#dispatch) { - console.error(`cannot deliver before vat is loaded`); - return; - } - await this.#dispatch(harden(payload.params) as VatDeliveryObject); - await Promise.all(this.#syscallsInFlight); - this.#syscallsInFlight.length = 0; - await this.replyToMessage(id, { - method: VatCommandMethod.deliver, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - params: this.#vatKVStore!.checkpoint(), - }); - break; - } - - case VatCommandMethod.initVat: { - await this.#initVat(payload.params.vatConfig, payload.params.state); - await this.replyToMessage(id, { - method: VatCommandMethod.initVat, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - params: this.#vatKVStore!.checkpoint(), + async #handleMessage(message: JsonRpcMessage): Promise { + if (isJsonRpcResponse(message)) { + this.#rpcClient.handleResponse(message.id as string, message); + } else if (isJsonRpcRequest(message)) { + try { + this.#rpcService.assertHasMethod(message.method); + const result = await this.#rpcService.execute( + message.method, + message.params, + ); + await this.#kernelStream.write({ + id: message.id, + result, + jsonrpc: '2.0', }); - break; - } - - case VatCommandMethod.ping: - await this.replyToMessage(id, { - method: VatCommandMethod.ping, - params: 'pong', + } catch (error) { + await this.#kernelStream.write({ + id: message.id, + error: serializeError(error), + jsonrpc: '2.0', }); - break; - - case VatCommandMethod.syscall: { - const [result, failure] = payload.params; - if (result !== 'ok') { - // A syscall can't fail as the result of user code misbehavior, but - // only from some kind of internal system problem, so if it happens we - // die. - const errMsg = `syscall failure ${failure}`; - console.error(errMsg); - await this.terminate(Error(errMsg)); - } - break; } - - default: - throw Error( - // @ts-expect-error Compile-time exhaustiveness check - `VatSupervisor received unexpected command method: "${payload.method}"`, - ); } } @@ -179,19 +169,29 @@ export class VatSupervisor { * @returns a syscall success result. */ executeSyscall(vso: VatSyscallObject): VatSyscallResult { - const payload: VatCommandReply['payload'] = { - method: VatCommandMethod.syscall, - params: vso, - }; this.#syscallsInFlight.push( - this.#commandStream.write({ - id: 'none', - payload, - }), + // XXX TODO: These all get rejected, so we have to catch them. See #deliver. + this.#rpcClient + .call('syscall', coerceVatSyscallObject(vso)) + .catch(() => undefined), ); return ['ok', null]; } + async #deliver(params: VatDeliveryObject): Promise { + if (!this.#dispatch) { + throw new Error(`cannot deliver before vat is loaded`); + } + await this.#dispatch(harden(params)); + + // XXX TODO: Actually handle the syscall results + this.#syscallsInFlight.length = 0; + this.#rpcClient.rejectAll(new Error('end of crank')); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#vatKVStore!.checkpoint(); + } + /** * Initialize the vat by loading its user code bundle and creating a liveslots * instance to manage it. @@ -199,12 +199,9 @@ export class VatSupervisor { * @param vatConfig - Configuration object describing the vat to be intialized. * @param state - A Map representing the current persistent state of the vat. * - * @returns a promise for the VRef of the new vat's root object. + * @returns a promise for a checkpoint of the new vat. */ - async #initVat( - vatConfig: VatConfig, - state: Map, - ): Promise { + readonly #initVat: InitVat = async (vatConfig, state) => { if (this.#loaded) { throw Error( 'VatSupervisor received initVat after user code already loaded', @@ -214,9 +211,9 @@ export class VatSupervisor { throw Error('VatSupervisor received initVat with bad config parameter'); } // XXX TODO: this check can and should go away once we can handle `bundleName` and `sourceSpec` too - if (!vatConfig.bundleSpec) { + if (!('bundleSpec' in vatConfig)) { throw Error( - 'for now, only bundleSpec is support in vatConfig specifications', + 'for now, only sourceSpec is support in vatConfig specifications', ); } this.#loaded = true; @@ -271,19 +268,6 @@ export class VatSupervisor { const serParam = marshal.toCapData(harden(parameters)) as CapData; await this.#dispatch(harden(['startVat', serParam])); - return ROOT_OBJECT_VREF; - } - - /** - * Reply to a message from the kernel. - * - * @param id - The id of the message to reply to. - * @param payload - The payload to reply with. - */ - async replyToMessage( - id: VatCommandReply['id'], - payload: VatCommandReply['payload'], - ): Promise { - await this.#commandStream.write({ id, payload }); - } + return this.#vatKVStore.checkpoint(); + }; } diff --git a/packages/kernel/src/VatSyscall.ts b/packages/kernel/src/VatSyscall.ts index 8107a2b2e..4f6e55edb 100644 --- a/packages/kernel/src/VatSyscall.ts +++ b/packages/kernel/src/VatSyscall.ts @@ -1,5 +1,4 @@ import type { - Message, VatOneResolution, VatSyscallObject, } from '@agoric/swingset-liveslots'; @@ -8,7 +7,8 @@ import { Logger } from '@ocap/utils'; import type { KernelQueue } from './KernelQueue.ts'; import type { KernelStore } from './store/index.ts'; import { parseRef } from './store/utils/parse-ref.ts'; -import type { VatId, KRef, RunQueueItemSend } from './types.ts'; +import { coerceMessage } from './types.ts'; +import type { Message, VatId, KRef, RunQueueItemSend } from './types.ts'; type VatSyscallProps = { vatId: VatId; @@ -178,7 +178,7 @@ export class VatSyscall { // [KRef, Message]; const [, target, message] = kso; log(`@@@@ ${vatId} syscall send ${target}<-${JSON.stringify(message)}`); - this.#handleSyscallSend(target, message); + this.#handleSyscallSend(target, coerceMessage(message)); break; } case 'subscribe': { diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index 5468cac7e..c353bc8ce 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -7,18 +7,10 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'ClusterConfigStruct', 'Kernel', - 'KernelCommandMethod', - 'KernelSendVatCommandStruct', - 'VatCommandMethod', 'VatConfigStruct', 'VatHandle', 'VatIdStruct', 'VatSupervisor', - 'isKernelCommand', - 'isKernelCommandReply', - 'isVatCommand', - 'isVatCommandPayloadUI', - 'isVatCommandReply', 'isVatConfig', 'isVatId', 'kser', diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 0568815b2..a2a5d6e17 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,15 +1,13 @@ -export * from './messages/index.ts'; export { Kernel } from './Kernel.ts'; export { VatHandle } from './VatHandle.ts'; export { VatSupervisor } from './VatSupervisor.ts'; -export type { Message } from '@agoric/swingset-liveslots'; export type { + ClusterConfig, + KRef, + Message, VatId, VatWorkerManager, - ClusterConfig, VatConfig, - VatCheckpoint, - KRef, } from './types.ts'; export { isVatId, diff --git a/packages/kernel/src/messages/index.ts b/packages/kernel/src/messages/index.ts deleted file mode 100644 index 7e9e8d419..000000000 --- a/packages/kernel/src/messages/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Kernel commands. - -export { - KernelCommandMethod, - isKernelCommand, - isKernelCommandReply, - KernelSendVatCommandStruct, -} from './kernel.ts'; -export type { KernelCommand, KernelCommandReply } from './kernel.ts'; - -// Vat commands. - -export { - VatCommandMethod, - isVatCommand, - isVatCommandReply, - isVatCommandPayloadUI, -} from './vat.ts'; -export type { - VatCommand, - VatCommandReply, - VatCommandReturnType, -} from './vat.ts'; diff --git a/packages/kernel/src/messages/kernel.test.ts b/packages/kernel/src/messages/kernel.test.ts deleted file mode 100644 index 016719b55..000000000 --- a/packages/kernel/src/messages/kernel.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isKernelCommand, isKernelCommandReply } from './kernel.ts'; - -describe('isKernelCommand', () => { - it.each` - value | expectedResult | description - ${123} | ${false} | ${'invalid command: primitive number'} - ${{ method: true, params: 'data' }} | ${false} | ${'invalid command: invalid type'} - ${{ method: 123, params: null }} | ${false} | ${'invalid command: invalid type and valid data'} - ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command: valid type and invalid data'} - `('returns $expectedResult for $description', ({ value, expectedResult }) => { - expect(isKernelCommand(value)).toBe(expectedResult); - }); -}); - -describe('isKernelCommandReply', () => { - it.each` - value | expectedResult | description - ${123} | ${false} | ${'invalid command reply: primitive number'} - ${{ method: true, params: 'data' }} | ${false} | ${'invalid command reply: invalid type'} - ${{ method: 123, params: null }} | ${false} | ${'invalid command reply: invalid type and valid data'} - ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command reply: valid type and invalid data'} - `('returns $expectedResult for $description', ({ value, expectedResult }) => { - expect(isKernelCommandReply(value)).toBe(expectedResult); - }); -}); diff --git a/packages/kernel/src/messages/kernel.ts b/packages/kernel/src/messages/kernel.ts deleted file mode 100644 index be421c1f8..000000000 --- a/packages/kernel/src/messages/kernel.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { object, union, is } from '@metamask/superstruct'; -import type { Infer, Struct } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; -import type { TypeGuard } from '@ocap/utils'; - -import { - VatMethodStructs, - VatTestCommandMethod, - VatTestMethodStructs, - VatTestReplyStructs, -} from './vat.ts'; -import { VatIdStruct } from '../types.ts'; - -export const KernelCommandMethod = { - ping: VatTestCommandMethod.ping, -} as const; - -// Explicitly annotated due to a TS2742 error that occurs during CommonJS -// builds by ts-bridge. -const KernelCommandStruct = union([VatTestMethodStructs.ping]) as Struct< - { - method: 'ping'; - params: Json[]; - }, - null ->; - -// Explicitly annotated due to a TS2742 error that occurs during CommonJS -// builds by ts-bridge. -const KernelCommandReplyStruct = union([VatTestReplyStructs.ping]) as Struct< - { - method: 'ping'; - params: string; - }, - null ->; - -export type KernelCommand = Infer; -export type KernelCommandReply = Infer; - -export const isKernelCommand: TypeGuard = ( - value: unknown, -): value is KernelCommand => is(value, KernelCommandStruct); - -export const isKernelCommandReply: TypeGuard = ( - value: unknown, -): value is KernelCommandReply => is(value, KernelCommandReplyStruct); - -export const KernelSendVatCommandStruct = object({ - id: VatIdStruct, - payload: union([VatMethodStructs.ping]), -}); diff --git a/packages/kernel/src/messages/vat.test.ts b/packages/kernel/src/messages/vat.test.ts deleted file mode 100644 index a881e6b0d..000000000 --- a/packages/kernel/src/messages/vat.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isVatCommand, isVatCommandReply, VatCommandMethod } from './vat.ts'; - -describe('isVatCommand', () => { - const payload = { method: VatCommandMethod.ping, params: [] }; - - it.each` - value | expectedResult | description - ${{ id: 'v0:1', payload }} | ${true} | ${'valid message id with valid payload'} - ${{ id: 'vat-message-id', payload }} | ${false} | ${'invalid id'} - ${{ id: 1, payload }} | ${false} | ${'numerical id'} - ${{ id: 'v0:1' }} | ${false} | ${'missing payload'} - `('returns $expectedResult for $description', ({ value, expectedResult }) => { - expect(isVatCommand(value)).toBe(expectedResult); - }); -}); - -describe('isVatCommandReply', () => { - it.each([ - { - name: 'ping reply', - value: { - id: 'v0:456', - payload: { - method: VatCommandMethod.ping, - params: 'pong', - }, - }, - expected: true, - }, - { - name: 'invalid id format', - value: { - id: 'invalid-id', - payload: { - method: VatCommandMethod.ping, - params: 'pong', - }, - }, - expected: false, - }, - { - name: 'invalid method', - value: { - id: 'test-vat:123', - payload: { - method: 'invalidMethod', - params: 'result', - }, - }, - expected: false, - }, - { - name: 'missing payload', - value: { - id: 'test-vat:123', - }, - expected: false, - }, - { - name: 'null value', - value: null, - expected: false, - }, - ])('should return $expected for $name', ({ value, expected }) => { - expect(isVatCommandReply(value)).toBe(expected); - }); -}); diff --git a/packages/kernel/src/messages/vat.ts b/packages/kernel/src/messages/vat.ts deleted file mode 100644 index 36db3f48c..000000000 --- a/packages/kernel/src/messages/vat.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { - object, - array, - record, - unknown, - tuple, - union, - map, - literal, - refine, - string, - boolean, - is, -} from '@metamask/superstruct'; -import type { Infer, Struct } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; -import { EmptyJsonArray } from '@ocap/utils'; - -import { - isVatId, - MessageStruct, - VatConfigStruct, - VatCheckpointStruct, - CapDataStruct, -} from '../types.ts'; -import type { VatId } from '../types.ts'; - -type VatMessageId = `${VatId}:${number}`; - -const isVatMessageId = (value: unknown): value is VatMessageId => - value === 'none' || - (typeof value === 'string' && - /^\w+:\d+$/u.test(value) && - isVatId(value.split(':')[0])); - -export const VatTestCommandMethod = { - ping: 'ping', -} as const; - -export const VatCommandMethod = { - ...VatTestCommandMethod, - initVat: 'initVat', - deliver: 'deliver', - // XXX due to the goofy way we define messages, the method and reply roles for `syscall` are swapped - syscall: 'syscall', -} as const; - -const VatMessageIdStruct = refine(string(), 'VatMessageId', isVatMessageId); - -/** - * This type only exists due to a TS2742 error that occurs during CommonJS - * builds by ts-bridge. - */ -export type VatTestMethodStructs = { - readonly ping: Struct< - { - method: 'ping'; - params: Json[]; - }, - { - method: Struct<'ping', 'ping'>; - params: Struct>; - } - >; -}; - -export const VatTestMethodStructs = { - [VatCommandMethod.ping]: object({ - method: literal(VatCommandMethod.ping), - params: EmptyJsonArray, - }), -} as VatTestMethodStructs; - -const VatOneResolutionStruct = tuple([string(), boolean(), CapDataStruct]); - -const VatDelivery = { - message: 'message', - notify: 'notify', - dropExports: 'dropExports', - retireExports: 'retireExports', - retireImports: 'retireImports', - changeVatOptions: 'changeVatOptions', - startVat: 'startVat', - stopVat: 'stopVat', - bringOutYourDead: 'bringOutYourDead', -} as const; - -const VatDeliveryStructs = { - [VatDelivery.message]: tuple([ - literal(VatDelivery.message), - string(), - MessageStruct, - ]), - [VatDelivery.notify]: tuple([ - literal(VatDelivery.notify), - array(VatOneResolutionStruct), - ]), - [VatDelivery.dropExports]: tuple([ - literal(VatDelivery.dropExports), - array(string()), - ]), - [VatDelivery.retireExports]: tuple([ - literal(VatDelivery.retireExports), - array(string()), - ]), - [VatDelivery.retireImports]: tuple([ - literal(VatDelivery.retireImports), - array(string()), - ]), - [VatDelivery.changeVatOptions]: tuple([ - literal(VatDelivery.changeVatOptions), - record(string(), unknown()), - ]), - [VatDelivery.startVat]: tuple([literal(VatDelivery.startVat), CapDataStruct]), - [VatDelivery.stopVat]: tuple([literal(VatDelivery.stopVat), CapDataStruct]), - [VatDelivery.bringOutYourDead]: tuple([ - literal(VatDelivery.bringOutYourDead), - ]), -}; - -const VatDeliveryStruct = union([ - VatDeliveryStructs.message, - VatDeliveryStructs.notify, - VatDeliveryStructs.dropExports, - VatDeliveryStructs.retireExports, - VatDeliveryStructs.retireImports, - VatDeliveryStructs.changeVatOptions, - VatDeliveryStructs.startVat, - VatDeliveryStructs.stopVat, - VatDeliveryStructs.bringOutYourDead, -]); - -const VatSyscall = { - send: 'send', - subscribe: 'subscribe', - resolve: 'resolve', - exit: 'exit', - dropImports: 'dropImports', - retireImports: 'retireImports', - retireExports: 'retireExports', - abandonExports: 'abandonExports', - // These are bogus, but are needed to keep TypeScript happy - callNow: 'callNow', - vatstoreGet: 'vatstoreGet', - vatstoreGetNextKey: 'vatstoreGetNextKey', - vatstoreSet: 'vatstoreSet', - vatstoreDelete: 'vatstoreDelete', -} as const; - -const VatSyscallStructs = { - [VatSyscall.send]: tuple([literal(VatSyscall.send), string(), MessageStruct]), - [VatSyscall.subscribe]: tuple([literal(VatSyscall.subscribe), string()]), - [VatSyscall.resolve]: tuple([ - literal(VatSyscall.resolve), - array(VatOneResolutionStruct), - ]), - [VatSyscall.exit]: tuple([ - literal(VatSyscall.exit), - boolean(), - CapDataStruct, - ]), - [VatSyscall.dropImports]: tuple([ - literal(VatSyscall.dropImports), - array(string()), - ]), - [VatSyscall.retireImports]: tuple([ - literal(VatSyscall.retireImports), - array(string()), - ]), - [VatSyscall.retireExports]: tuple([ - literal(VatSyscall.retireExports), - array(string()), - ]), - [VatSyscall.abandonExports]: tuple([ - literal(VatSyscall.abandonExports), - array(string()), - ]), - // These are bogus, but are needed to keep TypeScript happy - [VatSyscall.callNow]: tuple([ - literal(VatSyscall.callNow), - string(), - string(), - CapDataStruct, - ]), - [VatSyscall.vatstoreGet]: tuple([literal(VatSyscall.vatstoreGet), string()]), - [VatSyscall.vatstoreGetNextKey]: tuple([ - literal(VatSyscall.vatstoreGetNextKey), - string(), - ]), - [VatSyscall.vatstoreSet]: tuple([ - literal(VatSyscall.vatstoreSet), - string(), - string(), - ]), - [VatSyscall.vatstoreDelete]: tuple([ - literal(VatSyscall.vatstoreDelete), - string(), - ]), -} as const; - -const VatSyscallStruct = union([ - VatSyscallStructs.send, - VatSyscallStructs.subscribe, - VatSyscallStructs.resolve, - VatSyscallStructs.exit, - VatSyscallStructs.dropImports, - VatSyscallStructs.retireImports, - VatSyscallStructs.retireExports, - VatSyscallStructs.abandonExports, - // These are bogus, but are needed to keep TypeScript happy - VatSyscallStructs.callNow, - VatSyscallStructs.vatstoreGet, - VatSyscallStructs.vatstoreGetNextKey, - VatSyscallStructs.vatstoreSet, - VatSyscallStructs.vatstoreDelete, -]); - -export const VatMethodStructs = { - ...VatTestMethodStructs, - [VatCommandMethod.initVat]: object({ - method: literal(VatCommandMethod.initVat), - params: object({ - vatConfig: VatConfigStruct, - state: map(string(), string()), - }), - }), - [VatCommandMethod.deliver]: object({ - method: literal(VatCommandMethod.deliver), - params: VatDeliveryStruct, - }), - [VatCommandMethod.syscall]: object({ - method: literal(VatCommandMethod.syscall), - params: VatSyscallStruct, - }), -} as const; - -export type VatCommand = Infer; - -export const VatTestReplyStructs = { - [VatCommandMethod.ping]: object({ - method: literal(VatCommandMethod.ping), - params: string(), - }), -} as const; - -const VatReplyStructs = { - ...VatTestReplyStructs, - [VatCommandMethod.initVat]: object({ - method: literal(VatCommandMethod.initVat), - params: VatCheckpointStruct, - }), - [VatCommandMethod.deliver]: object({ - method: literal(VatCommandMethod.deliver), - params: VatCheckpointStruct, - }), - [VatCommandMethod.syscall]: object({ - method: literal(VatCommandMethod.syscall), - params: array(string()), - }), -} as const; - -const VatCommandStruct = object({ - id: VatMessageIdStruct, - payload: union([ - VatMethodStructs.ping, - VatMethodStructs.initVat, - VatMethodStructs.deliver, - VatReplyStructs.syscall, // Note swapped call/reply role - ]), -}); - -const VatCommandReplyStruct = object({ - id: VatMessageIdStruct, - payload: union([ - VatReplyStructs.ping, - VatReplyStructs.initVat, - VatReplyStructs.deliver, - VatMethodStructs.syscall, // Note swapped call/reply role - ]), -}); - -export type VatCommandReply = Infer; - -export const isVatCommand = (value: unknown): value is VatCommand => - is(value, VatCommandStruct); - -export const isVatCommandReply = (value: unknown): value is VatCommandReply => - is(value, VatCommandReplyStruct); - -export type VatReplyParams = Infer< - (typeof VatReplyStructs)[Method] ->['params']; - -export type VatCommandReturnType = { - [Method in keyof typeof VatReplyStructs]: VatReplyParams; -}; - -const VatCommandPayloadUIStruct = union([ - VatMethodStructs.ping, - VatMethodStructs.deliver, -]); -export type VatCommandPayloadUI = Infer; -export const isVatCommandPayloadUI = ( - value: unknown, -): value is VatCommandPayloadUI => is(value, VatCommandPayloadUIStruct); diff --git a/packages/kernel/src/rpc/index.test.ts b/packages/kernel/src/rpc/index.test.ts index 6cbb9870a..b29843d60 100644 --- a/packages/kernel/src/rpc/index.test.ts +++ b/packages/kernel/src/rpc/index.test.ts @@ -4,6 +4,15 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { - expect(Object.keys(indexModule).sort()).toStrictEqual(['vatWorkerService']); + expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'UiMethodRequestStruct', + 'kernelHandlers', + 'kernelMethodSpecs', + 'vatHandlers', + 'vatMethodSpecs', + 'vatSyscallHandlers', + 'vatSyscallMethodSpecs', + 'vatWorkerServiceMethodSpecs', + ]); }); }); diff --git a/packages/kernel/src/rpc/index.ts b/packages/kernel/src/rpc/index.ts index 8bd8be319..53882b025 100644 --- a/packages/kernel/src/rpc/index.ts +++ b/packages/kernel/src/rpc/index.ts @@ -1,3 +1,4 @@ -export * as vatWorkerService from './vat-worker-service/index.ts'; - -export type * from './vat-worker-service/index.ts'; +export * from './kernel/index.ts'; +export * from './vat-syscall/index.ts'; +export * from './vat-worker-service/index.ts'; +export * from './vat/index.ts'; diff --git a/packages/kernel/src/rpc/kernel/index.ts b/packages/kernel/src/rpc/kernel/index.ts new file mode 100644 index 000000000..44e64d7f6 --- /dev/null +++ b/packages/kernel/src/rpc/kernel/index.ts @@ -0,0 +1,19 @@ +import type { MethodRequest } from '@ocap/rpc-methods'; + +import { pingHandler, pingSpec } from '../vat/ping.ts'; + +export const kernelHandlers = { + ping: pingHandler, +} as const; + +export const kernelMethodSpecs = { + ping: pingSpec, +} as const; + +type Handlers = (typeof kernelHandlers)[keyof typeof kernelHandlers]; + +export type KernelMethod = Handlers['method']; + +export type KernelMethodSpec = (typeof kernelMethodSpecs)['ping']; + +export type KernelMethodRequest = MethodRequest; diff --git a/packages/kernel/src/rpc/vat-syscall/index.ts b/packages/kernel/src/rpc/vat-syscall/index.ts new file mode 100644 index 000000000..2416dbf30 --- /dev/null +++ b/packages/kernel/src/rpc/vat-syscall/index.ts @@ -0,0 +1,13 @@ +import { vatSyscallSpec, vatSyscallHandler } from './vat-syscall.ts'; + +export const vatSyscallHandlers = { + syscall: vatSyscallHandler, +} as const; + +export const vatSyscallMethodSpecs = { + syscall: vatSyscallSpec, +} as const; + +type Handlers = (typeof vatSyscallHandlers)[keyof typeof vatSyscallHandlers]; + +export type VatSyscallMethod = Handlers['method']; diff --git a/packages/kernel/src/rpc/vat-syscall/vat-syscall.test.ts b/packages/kernel/src/rpc/vat-syscall/vat-syscall.test.ts new file mode 100644 index 000000000..e518fa8c2 --- /dev/null +++ b/packages/kernel/src/rpc/vat-syscall/vat-syscall.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { vatSyscallHandler } from './vat-syscall.ts'; +import type { HandleSyscall } from './vat-syscall.ts'; + +describe('vatSyscallHandler', () => { + it('should initialize a vat', async () => { + const handleSyscall = vi.fn(async () => + Promise.resolve({ + checkpoint: 'fake', + }), + ) as unknown as HandleSyscall; + const result = await vatSyscallHandler.implementation({ handleSyscall }, [ + 'send', + 'test', + { + methargs: { body: 'test', slots: [] }, + result: null, + }, + ]); + expect(result).toStrictEqual({ + checkpoint: 'fake', + }); + }); + + it('should propagate errors from hooks', async () => { + const handleSyscall = vi.fn(() => { + throw new Error('fake'); + }); + await expect( + vatSyscallHandler.implementation({ handleSyscall }, [ + 'send', + 'test', + { + methargs: { body: 'test', slots: [] }, + result: null, + }, + ]), + ).rejects.toThrow('fake'); + }); +}); diff --git a/packages/kernel/src/rpc/vat-syscall/vat-syscall.ts b/packages/kernel/src/rpc/vat-syscall/vat-syscall.ts new file mode 100644 index 000000000..7b667f82d --- /dev/null +++ b/packages/kernel/src/rpc/vat-syscall/vat-syscall.ts @@ -0,0 +1,105 @@ +import type { VatSyscallResult } from '@agoric/swingset-liveslots'; +import { + tuple, + literal, + array, + string, + union, + boolean, + Struct, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { Handler, MethodSpec } from '@ocap/rpc-methods'; + +import { + CapDataStruct, + MessageStruct, + VatOneResolutionStruct, +} from '../../types.ts'; + +const SendStruct = tuple([literal('send'), string(), MessageStruct]); +const SubscribeStruct = tuple([literal('subscribe'), string()]); +const ResolveStruct = tuple([ + literal('resolve'), + array(VatOneResolutionStruct), +]); +const ExitStruct = tuple([literal('exit'), boolean(), CapDataStruct]); +const DropImportsStruct = tuple([literal('dropImports'), array(string())]); +const RetireImportsStruct = tuple([literal('retireImports'), array(string())]); +const RetireExportsStruct = tuple([literal('retireExports'), array(string())]); +const AbandonExportsStruct = tuple([ + literal('abandonExports'), + array(string()), +]); +// These are bogus, but are needed to keep TypeScript happy +const CallNowStruct = tuple([ + literal('callNow'), + string(), + string(), + CapDataStruct, +]); +const VatstoreGetStruct = tuple([literal('vatstoreGet'), string()]); +const VatstoreGetNextKeyStruct = tuple([ + literal('vatstoreGetNextKey'), + string(), +]); +const VatstoreSetStruct = tuple([literal('vatstoreSet'), string(), string()]); +const VatstoreDeleteStruct = tuple([literal('vatstoreDelete'), string()]); + +const VatSyscallParamsStruct = union([ + SendStruct, + SubscribeStruct, + ResolveStruct, + ExitStruct, + DropImportsStruct, + RetireImportsStruct, + RetireExportsStruct, + AbandonExportsStruct, + // These are bogus, but are needed to keep TypeScript happy + CallNowStruct, + VatstoreGetStruct, + VatstoreGetNextKeyStruct, + VatstoreSetStruct, + VatstoreDeleteStruct, +]); + +type VatSyscallParams = Infer; + +const VatSyscallResultStruct: Struct = union([ + tuple([ + literal('ok'), + union([CapDataStruct, string(), array(string()), literal(null)]), + ]), + tuple([literal('error'), string()]), +]); + +export const vatSyscallSpec: MethodSpec< + 'syscall', + VatSyscallParams, + Promise +> = { + method: 'syscall', + params: VatSyscallParamsStruct, + result: VatSyscallResultStruct, +} as const; + +export type HandleSyscall = ( + params: VatSyscallParams, +) => Promise; + +type SyscallHooks = { + handleSyscall: HandleSyscall; +}; + +export const vatSyscallHandler: Handler< + 'syscall', + VatSyscallParams, + Promise, + SyscallHooks +> = { + ...vatSyscallSpec, + hooks: { handleSyscall: true }, + implementation: async ({ handleSyscall }, params) => { + return await handleSyscall(params); + }, +} as const; diff --git a/packages/kernel/src/rpc/vat-worker-service/index.ts b/packages/kernel/src/rpc/vat-worker-service/index.ts index 4a13a1a4f..e6e99bb7f 100644 --- a/packages/kernel/src/rpc/vat-worker-service/index.ts +++ b/packages/kernel/src/rpc/vat-worker-service/index.ts @@ -13,10 +13,11 @@ export type VatWorkerServiceMethodSpecs = | typeof terminateSpec | typeof terminateAllSpec; -export const methodSpecs: MethodSpecRecord = { - launch: launchSpec, - terminate: terminateSpec, - terminateAll: terminateAllSpec, -} as const; +export const vatWorkerServiceMethodSpecs: MethodSpecRecord = + { + launch: launchSpec, + terminate: terminateSpec, + terminateAll: terminateAllSpec, + } as const; export type VatWorkerServiceMethod = VatWorkerServiceMethodSpecs['method']; diff --git a/packages/kernel/src/rpc/vat/deliver.test.ts b/packages/kernel/src/rpc/vat/deliver.test.ts new file mode 100644 index 000000000..90f130f61 --- /dev/null +++ b/packages/kernel/src/rpc/vat/deliver.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { deliverHandler } from './deliver.ts'; +import type { HandleDelivery } from './deliver.ts'; + +describe('deliverHandler', () => { + it('should deliver a message', async () => { + const handleDelivery = vi.fn(async () => + Promise.resolve({ checkpoint: 'fake' }), + ) as unknown as HandleDelivery; + const result = deliverHandler.implementation({ handleDelivery }, [ + 'message', + 'test', + { + methargs: { body: 'test', slots: [] }, + result: null, + }, + ]); + expect(await result).toStrictEqual({ + checkpoint: 'fake', + }); + }); + + it('should propagate errors from hooks', async () => { + const handleDelivery = vi.fn(() => { + throw new Error('fake'); + }); + await expect( + deliverHandler.implementation({ handleDelivery }, [ + 'message', + 'test', + { + methargs: { body: 'test', slots: [] }, + result: null, + }, + ]), + ).rejects.toThrow('fake'); + }); +}); diff --git a/packages/kernel/src/rpc/vat/deliver.ts b/packages/kernel/src/rpc/vat/deliver.ts new file mode 100644 index 000000000..4e94c363e --- /dev/null +++ b/packages/kernel/src/rpc/vat/deliver.ts @@ -0,0 +1,105 @@ +import { + tuple, + literal, + array, + string, + record, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { UnsafeJsonStruct } from '@metamask/utils'; +import type { Handler, MethodSpec } from '@ocap/rpc-methods'; +import type { VatCheckpoint } from '@ocap/store'; + +import { VatCheckpointStruct } from './shared.ts'; +import { + CapDataStruct, + MessageStruct, + VatOneResolutionStruct, +} from '../../types.ts'; + +const MessageDeliveryStruct = tuple([ + literal('message'), + string(), + MessageStruct, +]); + +const NotifyDeliveryStruct = tuple([ + literal('notify'), + array(VatOneResolutionStruct), +]); + +const DropExportsDeliveryStruct = tuple([ + literal('dropExports'), + array(string()), +]); + +const RetireExportsDeliveryStruct = tuple([ + literal('retireExports'), + array(string()), +]); + +const RetireImportsDeliveryStruct = tuple([ + literal('retireImports'), + array(string()), +]); + +const ChangeVatOptionsDeliveryStruct = tuple([ + literal('changeVatOptions'), + record(string(), UnsafeJsonStruct), +]); + +const StartVatDeliveryStruct = tuple([literal('startVat'), CapDataStruct]); + +const StopVatDeliveryStruct = tuple([literal('stopVat'), CapDataStruct]); + +const BringOutYourDeadDeliveryStruct = tuple([literal('bringOutYourDead')]); + +const VatDeliveryParamsStruct = union([ + MessageDeliveryStruct, + NotifyDeliveryStruct, + DropExportsDeliveryStruct, + RetireExportsDeliveryStruct, + RetireImportsDeliveryStruct, + ChangeVatOptionsDeliveryStruct, + StartVatDeliveryStruct, + StopVatDeliveryStruct, + BringOutYourDeadDeliveryStruct, +]); + +type VatDeliveryParams = Infer; + +export type DeliverSpec = MethodSpec< + 'deliver', + VatDeliveryParams, + Promise +>; + +export const deliverSpec: DeliverSpec = { + method: 'deliver', + params: VatDeliveryParamsStruct, + result: VatCheckpointStruct, +} as const; + +export type HandleDelivery = ( + params: VatDeliveryParams, +) => Promise; + +type DeliverHooks = { + handleDelivery: HandleDelivery; +}; + +export type DeliverHandler = Handler< + 'deliver', + VatDeliveryParams, + Promise, + DeliverHooks +>; + +export const deliverHandler: DeliverHandler = { + ...deliverSpec, + hooks: { handleDelivery: true }, + implementation: async ({ handleDelivery }, params) => { + return await handleDelivery(params); + }, +} as const; diff --git a/packages/kernel/src/rpc/vat/index.ts b/packages/kernel/src/rpc/vat/index.ts new file mode 100644 index 000000000..c66bfab8f --- /dev/null +++ b/packages/kernel/src/rpc/vat/index.ts @@ -0,0 +1,54 @@ +import { is, refine, Struct } from '@metamask/superstruct'; +import { JsonRpcRequestStruct } from '@metamask/utils'; +import type { MethodRequest } from '@ocap/rpc-methods'; + +import { deliverSpec, deliverHandler } from './deliver.ts'; +import type { DeliverSpec, DeliverHandler } from './deliver.ts'; +import { initVatSpec, initVatHandler } from './initVat.ts'; +import type { InitVatSpec, InitVatHandler } from './initVat.ts'; +import { pingSpec, pingHandler } from './ping.ts'; +import type { PingSpec, PingHandler } from './ping.ts'; + +// The handler and spec exports are explicitly annotated due to a TS2742 error +// that occurs during CommonJS builds by ts-bridge. + +export const vatHandlers = { + deliver: deliverHandler, + initVat: initVatHandler, + ping: pingHandler, +} as { + deliver: DeliverHandler; + initVat: InitVatHandler; + ping: PingHandler; +}; + +export const vatMethodSpecs = { + deliver: deliverSpec, + initVat: initVatSpec, + ping: pingSpec, +} as { + deliver: DeliverSpec; + initVat: InitVatSpec; + ping: PingSpec; +}; + +type Handlers = (typeof vatHandlers)[keyof typeof vatHandlers]; + +export type VatMethod = Handlers['method']; + +export type VatUiMethod = + | (typeof vatMethodSpecs)['deliver'] + | (typeof vatMethodSpecs)['ping']; + +export type UiMethodRequest = MethodRequest; + +export const UiMethodRequestStruct = refine( + JsonRpcRequestStruct, + 'UiMethodRequest', + (value) => { + return ( + (value.method === 'ping' && is(value.params, pingSpec.params)) || + (value.method === 'deliver' && is(value.params, deliverSpec.params)) + ); + }, +) as Struct; diff --git a/packages/kernel/src/rpc/vat/initVat.test.ts b/packages/kernel/src/rpc/vat/initVat.test.ts new file mode 100644 index 000000000..6b47c64d3 --- /dev/null +++ b/packages/kernel/src/rpc/vat/initVat.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { initVatHandler } from './initVat.ts'; +import type { InitVat } from './initVat.ts'; + +describe('initVatHandler', () => { + it('should initialize a vat', async () => { + const initVat = vi.fn(() => ({ checkpoint: 'fake' })) as unknown as InitVat; + const result = initVatHandler.implementation( + { initVat }, + { + vatConfig: { sourceSpec: 'test' }, + state: [], + }, + ); + expect(await result).toStrictEqual({ + checkpoint: 'fake', + }); + }); + + it('should propagate errors from hooks', async () => { + const initVat = vi.fn(() => { + throw new Error('fake'); + }); + await expect( + initVatHandler.implementation( + { initVat }, + { + vatConfig: { sourceSpec: 'test' }, + state: [], + }, + ), + ).rejects.toThrow('fake'); + }); +}); diff --git a/packages/kernel/src/rpc/vat/initVat.ts b/packages/kernel/src/rpc/vat/initVat.ts new file mode 100644 index 000000000..d5e304d0d --- /dev/null +++ b/packages/kernel/src/rpc/vat/initVat.ts @@ -0,0 +1,47 @@ +import { array, object, string, tuple } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import type { VatCheckpoint } from '@ocap/store'; + +import { VatCheckpointStruct } from './shared.ts'; +import { VatConfigStruct } from '../../types.ts'; +import type { VatConfig } from '../../types.ts'; + +const paramsStruct = object({ + vatConfig: VatConfigStruct, + state: array(tuple([string(), string()])), +}); + +type Params = Infer; + +export type InitVatSpec = MethodSpec<'initVat', Params, Promise>; + +export const initVatSpec: InitVatSpec = { + method: 'initVat', + params: paramsStruct, + result: VatCheckpointStruct, +}; + +export type InitVat = ( + vatConfig: VatConfig, + state: Map, +) => Promise; + +type InitVatHooks = { + initVat: InitVat; +}; + +export type InitVatHandler = Handler< + 'initVat', + Params, + Promise, + InitVatHooks +>; + +export const initVatHandler: InitVatHandler = { + ...initVatSpec, + hooks: { initVat: true }, + implementation: async ({ initVat }, params) => { + return await initVat(params.vatConfig, new Map(params.state)); + }, +}; diff --git a/packages/kernel/src/rpc/vat/ping.test.ts b/packages/kernel/src/rpc/vat/ping.test.ts new file mode 100644 index 000000000..2266b2b4b --- /dev/null +++ b/packages/kernel/src/rpc/vat/ping.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; + +import { pingHandler } from './ping.ts'; + +describe('pingHandler', () => { + it('should return "pong"', () => { + const result = pingHandler.implementation({}, []); + expect(result).toBe('pong'); + }); +}); diff --git a/packages/kernel/src/rpc/vat/ping.ts b/packages/kernel/src/rpc/vat/ping.ts new file mode 100644 index 000000000..0f0ff3555 --- /dev/null +++ b/packages/kernel/src/rpc/vat/ping.ts @@ -0,0 +1,20 @@ +import { string } from '@metamask/superstruct'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; + +export type PingSpec = MethodSpec<'ping', EmptyJsonArray, string>; + +export const pingSpec: PingSpec = { + method: 'ping', + params: EmptyJsonArray, + result: string(), +} as const; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type PingHandler = Handler<'ping', EmptyJsonArray, string, {}>; + +export const pingHandler: PingHandler = { + ...pingSpec, + hooks: {}, + implementation: () => 'pong', +}; diff --git a/packages/kernel/src/rpc/vat/shared.ts b/packages/kernel/src/rpc/vat/shared.ts new file mode 100644 index 000000000..ed4384711 --- /dev/null +++ b/packages/kernel/src/rpc/vat/shared.ts @@ -0,0 +1,8 @@ +import type { Struct } from '@metamask/superstruct'; +import { tuple, array, string } from '@metamask/superstruct'; +import type { VatCheckpoint } from '@ocap/store'; + +export const VatCheckpointStruct: Struct = tuple([ + array(tuple([string(), string()])), + array(string()), +]); diff --git a/packages/kernel/src/services/garbage-collection.test.ts b/packages/kernel/src/services/garbage-collection.test.ts index 8e0150789..79a34f3b4 100644 --- a/packages/kernel/src/services/garbage-collection.test.ts +++ b/packages/kernel/src/services/garbage-collection.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { processGCActionSet } from './garbage-collection.ts'; import { makeMapKernelDatabase } from '../../test/storage.ts'; import { makeKernelStore } from '../store/index.ts'; -import { RunQueueItemType } from '../types.ts'; describe('garbage-collection', () => { describe('processGCActionSet', () => { @@ -35,7 +34,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ - type: RunQueueItemType.dropExports, + type: 'dropExports', vatId: 'v1', krefs: [ko1], }); @@ -56,7 +55,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ - type: RunQueueItemType.retireExports, + type: 'retireExports', vatId: 'v1', krefs: [ko1], }); @@ -76,7 +75,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ - type: RunQueueItemType.retireImports, + type: 'retireImports', vatId: 'v2', krefs: [ko1], }); @@ -106,7 +105,7 @@ describe('garbage-collection', () => { // Process first action - should be dropExport let result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ - type: RunQueueItemType.dropExports, + type: 'dropExports', vatId: 'v1', krefs: [ko1], }); @@ -114,7 +113,7 @@ describe('garbage-collection', () => { // Process second action - should be retireExport result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ - type: RunQueueItemType.retireExports, + type: 'retireExports', vatId: 'v1', krefs: [ko2], }); @@ -141,7 +140,7 @@ describe('garbage-collection', () => { // Process first action - should be v1 let result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ - type: RunQueueItemType.dropExports, + type: 'dropExports', vatId: 'v1', krefs: [ko2], }); @@ -149,7 +148,7 @@ describe('garbage-collection', () => { // Process second action - should be v2 result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ - type: RunQueueItemType.dropExports, + type: 'dropExports', vatId: 'v2', krefs: [ko1], }); diff --git a/packages/kernel/src/store/methods/gc.test.ts b/packages/kernel/src/store/methods/gc.test.ts index dc61999cd..2c0cb8e8e 100644 --- a/packages/kernel/src/store/methods/gc.test.ts +++ b/packages/kernel/src/store/methods/gc.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { makeMapKernelDatabase } from '../../../test/storage.ts'; -import { RunQueueItemType } from '../../types.ts'; import type { GCAction } from '../../types.ts'; import { makeKernelStore } from '../index.ts'; @@ -103,7 +102,7 @@ describe('GC methods', () => { // Verify they are processed in order vatIds.forEach((vatId) => { expect(kernelStore.nextReapAction()).toStrictEqual({ - type: RunQueueItemType.bringOutYourDead, + type: 'bringOutYourDead', vatId, }); }); @@ -119,12 +118,12 @@ describe('GC methods', () => { // Should only process v1 once expect(kernelStore.nextReapAction()).toStrictEqual({ - type: RunQueueItemType.bringOutYourDead, + type: 'bringOutYourDead', vatId: 'v1', }); expect(kernelStore.nextReapAction()).toStrictEqual({ - type: RunQueueItemType.bringOutYourDead, + type: 'bringOutYourDead', vatId: 'v2', }); diff --git a/packages/kernel/src/store/methods/gc.ts b/packages/kernel/src/store/methods/gc.ts index 2c31a46e0..2252f8054 100644 --- a/packages/kernel/src/store/methods/gc.ts +++ b/packages/kernel/src/store/methods/gc.ts @@ -13,11 +13,7 @@ import type { GCAction, RunQueueItemBringOutYourDead, } from '../../types.ts'; -import { - insistGCActionType, - insistVatId, - RunQueueItemType, -} from '../../types.ts'; +import { insistGCActionType, insistVatId } from '../../types.ts'; import type { StoreContext } from '../types.ts'; import { insistKernelType, parseKernelSlot } from '../utils/kernel-slots.ts'; @@ -97,7 +93,7 @@ export function getGCMethods(ctx: StoreContext) { if (queue.length > 0) { const vatId = queue.shift(); ctx.reapQueue.set(JSON.stringify(queue)); - return harden({ type: RunQueueItemType.bringOutYourDead, vatId }); + return harden({ type: 'bringOutYourDead', vatId }); } return undefined; } diff --git a/packages/kernel/src/store/methods/promise.ts b/packages/kernel/src/store/methods/promise.ts index 84d76ad50..1fd388646 100644 --- a/packages/kernel/src/store/methods/promise.ts +++ b/packages/kernel/src/store/methods/promise.ts @@ -1,4 +1,3 @@ -import type { Message } from '@agoric/swingset-liveslots'; import { Fail } from '@endo/errors'; import type { CapData } from '@endo/marshal'; @@ -9,6 +8,7 @@ import { getRefCountMethods } from './refcount.ts'; import type { KRef, KernelPromise, + Message, PromiseState, RunQueueItemSend, VatId, diff --git a/packages/kernel/src/store/methods/translators.ts b/packages/kernel/src/store/methods/translators.ts index 1611b1338..f8f764b1c 100644 --- a/packages/kernel/src/store/methods/translators.ts +++ b/packages/kernel/src/store/methods/translators.ts @@ -1,11 +1,11 @@ import type { - Message, VatOneResolution, VatSyscallObject, } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; -import type { VatId, KRef, VRef } from '../../types.ts'; +import { coerceMessage } from '../../types.ts'; +import type { Message, VatId, KRef, VRef } from '../../types.ts'; import type { StoreContext } from '../types.ts'; import { getCListMethods } from './clist.ts'; import { getVatMethods } from './vat.ts'; @@ -85,7 +85,7 @@ export function getTranslators(ctx: StoreContext) { const result = message.result ? translateRefKtoV(vatId, message.result, true) : message.result; - const vatMessage = { ...message, methargs, result }; + const vatMessage = coerceMessage({ ...message, methargs, result }); return vatMessage; } @@ -162,7 +162,9 @@ export function getTranslators(ctx: StoreContext) { kso = [ op, translateRefVtoK(vatId, target), - translateMessageVtoK(vatId, message), + // @ts-expect-error: Agoric's Message type has the property `result: string | undefined | null`. + // Ours is `result?: string | null`. We can safely ignore the `undefined` case. + translateMessageVtoK(vatId, coerceMessage(message)), ]; break; } diff --git a/packages/kernel/src/store/vat-kv-store.test.ts b/packages/kernel/src/store/vat-kv-store.test.ts index 291790559..e42703d6f 100644 --- a/packages/kernel/src/store/vat-kv-store.test.ts +++ b/packages/kernel/src/store/vat-kv-store.test.ts @@ -25,15 +25,15 @@ describe('VatKVStore', () => { const checkpoint = vatstore.checkpoint(); expect(checkpoint).toStrictEqual([ - new Map([ + [ ['key2', 'revisedValue2'], ['key4', 'value4'], - ]), - new Set(['key1']), + ], + ['key1'], ]); const checkpoint2 = vatstore.checkpoint(); - expect(checkpoint2).toStrictEqual([new Map(), new Set()]); + expect(checkpoint2).toStrictEqual([[], []]); expect(backingStore).toStrictEqual( new Map([ diff --git a/packages/kernel/src/store/vat-kv-store.ts b/packages/kernel/src/store/vat-kv-store.ts index 3b9c16138..08e109846 100644 --- a/packages/kernel/src/store/vat-kv-store.ts +++ b/packages/kernel/src/store/vat-kv-store.ts @@ -1,13 +1,8 @@ /* eslint-disable no-lonely-if, no-else-return */ -import type { KVStore } from '@ocap/store'; +import type { VatKVStore, VatCheckpoint } from '@ocap/store'; -import type { VatCheckpoint } from '../types.ts'; import { keySearch } from '../utils/key-search.ts'; -export type VatKVStore = KVStore & { - checkpoint(): VatCheckpoint; -}; - /** * Create an in-memory VatKVStore for a vat, backed by a Map and tracking * changes so that they can be reported at the end of a crank. @@ -17,8 +12,8 @@ export type VatKVStore = KVStore & { * @returns a VatKVStore wrapped around `state`. */ export function makeVatKVStore(state: Map): VatKVStore { - let sets: Map = new Map(); - let deletes: Set = new Set(); + const sets: Map = new Map(); + const deletes: Set = new Set(); let keyCache: string[] | null = null; let lastNextKey: string | null = null; let lastNextKeyIndex: number = -1; @@ -72,9 +67,12 @@ export function makeVatKVStore(state: Map): VatKVStore { keyCache = null; }, checkpoint(): VatCheckpoint { - const result: VatCheckpoint = [sets, deletes]; - sets = new Map(); - deletes = new Set(); + const result: VatCheckpoint = [ + Array.from(sets.entries()), + Array.from(deletes), + ]; + sets.clear(); + deletes.clear(); return result; }, }; diff --git a/packages/kernel/src/types.test.ts b/packages/kernel/src/types.test.ts index 3ac22ccb2..39e59e4b5 100644 --- a/packages/kernel/src/types.test.ts +++ b/packages/kernel/src/types.test.ts @@ -7,7 +7,6 @@ import { isGCActionType, insistGCActionType, isGCAction, - RunQueueItemType, isVatMessageId, } from './types.ts'; @@ -118,15 +117,10 @@ describe('insistMessage', () => { describe('queueTypeFromActionType', () => { it('maps GC action types to queue event types', () => { - expect(queueTypeFromActionType.get('dropExport')).toBe( - RunQueueItemType.dropExports, - ); - expect(queueTypeFromActionType.get('retireExport')).toBe( - RunQueueItemType.retireExports, - ); - expect(queueTypeFromActionType.get('retireImport')).toBe( - RunQueueItemType.retireImports, - ); + // Note: From singular to plural + expect(queueTypeFromActionType.get('dropExport')).toBe('dropExports'); + expect(queueTypeFromActionType.get('retireExport')).toBe('retireExports'); + expect(queueTypeFromActionType.get('retireImport')).toBe('retireImports'); expect(queueTypeFromActionType.size).toBe(3); }); }); diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 56732edaa..4f317c8f4 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -1,18 +1,18 @@ -import type { Message } from '@agoric/swingset-liveslots'; +import type { + Message as SwingsetMessage, + VatSyscallObject, + VatSyscallSend, +} from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; import { define, is, - never, object, - optional, string, array, record, union, tuple, - map, - set, literal, boolean, exactOptional, @@ -21,8 +21,8 @@ import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { UnsafeJsonStruct } from '@metamask/utils'; import type { DuplexStream } from '@ocap/streams'; +import type { JsonRpcMessage } from '@ocap/utils'; -import type { VatCommandReply, VatCommand } from './messages/vat.ts'; import { Fail } from './utils/assert.ts'; export type VatId = string; @@ -42,40 +42,111 @@ export const CapDataStruct = object({ slots: array(string()), }); -export type RunQueueItemSend = { - type: 'send'; - target: KRef; - message: Message; -}; +export const VatOneResolutionStruct = tuple([ + string(), + boolean(), + CapDataStruct, +]); -export type RunQueueItemNotify = { - type: 'notify'; - vatId: VatId; - kpid: KRef; -}; +export const MessageStruct = object({ + methargs: CapDataStruct, + result: exactOptional(union([string(), literal(null)])), +}); -export type RunQueueItemGCAction = { - type: GCRunQueueType; - vatId: VatId; - krefs: KRef[]; -}; +/** + * JSON-RPC-compatible Message type, originally from @agoric/swingset-liveslots. + */ +export type Message = Infer; -export type RunQueueItemBringOutYourDead = { - type: 'bringOutYourDead'; - vatId: VatId; -}; +/** + * Coerce a {@link SwingsetMessage} to our own JSON-RPC-compatible {@link Message}. + * + * @param message - The SwingsetMessage to coerce. + * @returns The coerced Message. + */ +export function coerceMessage(message: SwingsetMessage): Message { + if (message.result === undefined) { + delete (message as Message).result; + } + return message as Message; +} -export type RunQueueItem = - | RunQueueItemSend - | RunQueueItemNotify - | RunQueueItemGCAction - | RunQueueItemBringOutYourDead; +type JsonVatSyscallObject = + | Exclude + | ['send', string, Message]; -export const MessageStruct = object({ - methargs: CapDataStruct, - result: union([string(), literal(undefined), literal(null)]), +/** + * Coerce a {@link VatSyscallObject} to a JSON-RPC-compatible {@link JsonVatSyscallObject}. + * + * @param vso - The VatSyscallObject to coerce. + * @returns The coerced VatSyscallObject. + */ +export function coerceVatSyscallObject( + vso: VatSyscallObject, +): JsonVatSyscallObject { + if (vso[0] === 'send') { + return ['send', vso[1], coerceMessage(vso[2])]; + } + return vso as JsonVatSyscallObject; +} + +const RunQueueItemSendStruct = object({ + type: literal('send'), + target: string(), // KRef + message: MessageStruct, +}); + +export type RunQueueItemSend = Infer; + +const RunQueueItemNotifyStruct = object({ + type: literal('notify'), + vatId: string(), + kpid: string(), +}); + +export type RunQueueItemNotify = Infer; + +const GCRunQueueTypeStruct = union([ + literal('dropExports'), + literal('retireExports'), + literal('retireImports'), +]); + +export type GCRunQueueType = Infer; + +export type GCActionType = 'dropExport' | 'retireExport' | 'retireImport'; +export const actionTypePriorities: GCActionType[] = [ + 'dropExport', + 'retireExport', + 'retireImport', +]; + +const RunQueueItemGCActionStruct = object({ + type: GCRunQueueTypeStruct, + vatId: string(), // VatId + krefs: array(string()), // KRefs }); +export type RunQueueItemGCAction = Infer; + +const RunQueueItemBringOutYourDeadStruct = object({ + type: literal('bringOutYourDead'), + vatId: string(), +}); + +export type RunQueueItemBringOutYourDead = Infer< + typeof RunQueueItemBringOutYourDeadStruct +>; + +export const RunQueueItemStruct = union([ + RunQueueItemSendStruct, + RunQueueItemNotifyStruct, + RunQueueItemGCActionStruct, + RunQueueItemBringOutYourDeadStruct, +]); + +export type RunQueueItem = Infer; + /** * Assert that a value is a valid message. * @@ -86,50 +157,6 @@ export function insistMessage(value: unknown): asserts value is Message { is(value, MessageStruct) || Fail`not a valid message`; } -export const RunQueueItemType = { - send: 'send', - notify: 'notify', - dropExports: 'dropExports', - retireExports: 'retireExports', - retireImports: 'retireImports', - bringOutYourDead: 'bringOutYourDead', -} as const; - -const RunQueueItemStructs = { - [RunQueueItemType.send]: object({ - type: literal(RunQueueItemType.send), - target: string(), - message: MessageStruct, - }), - [RunQueueItemType.notify]: object({ - type: literal(RunQueueItemType.notify), - vatId: string(), - kpid: string(), - }), - [RunQueueItemType.dropExports]: object({ - type: literal(RunQueueItemType.dropExports), - }), - [RunQueueItemType.retireExports]: object({ - type: literal(RunQueueItemType.retireExports), - }), - [RunQueueItemType.retireImports]: object({ - type: literal(RunQueueItemType.retireImports), - }), - [RunQueueItemType.bringOutYourDead]: object({ - type: literal(RunQueueItemType.bringOutYourDead), - vatId: string(), - }), -}; - -export const RunQueueItemStruct = union([ - RunQueueItemStructs.send, - RunQueueItemStructs.notify, - RunQueueItemStructs.dropExports, - RunQueueItemStructs.retireExports, - RunQueueItemStructs.retireImports, - RunQueueItemStructs.bringOutYourDead, -]); - // Per-endpoint persistent state type EndpointState = { name: string; @@ -211,7 +238,7 @@ export type VatWorkerManager = { launch: ( vatId: VatId, vatConfig: VatConfig, - ) => Promise>; + ) => Promise>; /** * Terminate a worker identified by its vat id. * @@ -231,24 +258,6 @@ export type VatWorkerManager = { // Cluster configuration -type UserCodeSpec = - // Ugly but working hack, absent TypeScript having a genuine exclusive union construct. - | { - sourceSpec: string; - bundleSpec?: never; - bundleName?: never; - } - | { - sourceSpec?: never; - bundleSpec: string; - bundleName?: never; - } - | { - sourceSpec?: never; - bundleSpec?: never; - bundleName: string; - }; - export type VatConfig = UserCodeSpec & { creationOptions?: Record; parameters?: Record; @@ -257,21 +266,17 @@ export type VatConfig = UserCodeSpec & { const UserCodeSpecStruct = union([ object({ sourceSpec: string(), - bundleSpec: optional(never()), - bundleName: optional(never()), }), object({ - sourceSpec: optional(never()), bundleSpec: string(), - bundleName: optional(never()), }), object({ - sourceSpec: optional(never()), - bundleSpec: optional(never()), bundleName: string(), }), ]); +type UserCodeSpec = Infer; + export const VatConfigStruct = define('VatConfig', (value) => { if (!value) { return false; @@ -308,28 +313,14 @@ export const isClusterConfig = (value: unknown): value is ClusterConfig => export type UserCodeStartFn = (parameters?: Record) => object; -export type VatCheckpoint = [Map, Set]; - -export const VatCheckpointStruct = tuple([ - map(string(), string()), - set(string()), -]); - -export type GCRunQueueType = 'dropExports' | 'retireExports' | 'retireImports'; -export type GCActionType = 'dropExport' | 'retireExport' | 'retireImport'; -export const actionTypePriorities: GCActionType[] = [ - 'dropExport', - 'retireExport', - 'retireImport', -]; - /** * A mapping of GC action type to queue event type. */ export const queueTypeFromActionType = new Map([ - ['dropExport', RunQueueItemType.dropExports], - ['retireExport', RunQueueItemType.retireExports], - ['retireImport', RunQueueItemType.retireImports], + // Note: From singular to plural + ['dropExport', 'dropExports'], + ['retireExport', 'retireExports'], + ['retireImport', 'retireImports'], ]); export const isGCActionType = (value: unknown): value is GCActionType => diff --git a/packages/kernel/test/storage.ts b/packages/kernel/test/storage.ts index 9c5d2067e..75fdada48 100644 --- a/packages/kernel/test/storage.ts +++ b/packages/kernel/test/storage.ts @@ -85,18 +85,20 @@ type ClearableVatStore = VatStore & { * @returns the mock {@link VatStore}. */ function makeMapVatStore(_vatID: string): ClearableVatStore { - const map = new Map(); + const kvData: Map = new Map(); return { - getKVData: () => map, - updateKVData: (sets: Map, deletes: Set) => { - for (const [key, value] of sets.entries()) { - map.set(key, value); + getKVData: () => Array.from(kvData.entries()), + updateKVData: (sets: [string, string][], deletes: string[]) => { + for (const [key, value] of sets) { + kvData.set(key, value); } - for (const key of deletes.values()) { - map.delete(key); + for (const key of deletes) { + kvData.delete(key); } }, - clear: () => map.clear(), + clear: () => { + kvData.clear(); + }, }; } diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index b48cba0c5..8b6392c28 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@endo/promise-kit": "^1.1.10", + "@metamask/utils": "^11.4.0", "@ocap/kernel": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/store": "workspace:^", diff --git a/packages/nodejs/src/kernel/VatWorkerManager.ts b/packages/nodejs/src/kernel/VatWorkerManager.ts index 825a92708..dc378e279 100644 --- a/packages/nodejs/src/kernel/VatWorkerManager.ts +++ b/packages/nodejs/src/kernel/VatWorkerManager.ts @@ -1,15 +1,9 @@ import { makePromiseKit } from '@endo/promise-kit'; -import { isVatCommandReply } from '@ocap/kernel'; -import type { - VatWorkerManager, - VatId, - VatCommand, - VatCommandReply, -} from '@ocap/kernel'; +import type { VatWorkerManager, VatId } from '@ocap/kernel'; import { NodeWorkerDuplexStream } from '@ocap/streams'; import type { DuplexStream } from '@ocap/streams'; -import { makeLogger } from '@ocap/utils'; -import type { Logger } from '@ocap/utils'; +import { isJsonRpcMessage, makeLogger } from '@ocap/utils'; +import type { JsonRpcMessage, Logger } from '@ocap/utils'; import { Worker as NodeWorker } from 'node:worker_threads'; // Worker file loads from the built dist directory, requires rebuild after change @@ -26,7 +20,7 @@ export class NodejsVatWorkerManager implements VatWorkerManager { workers = new Map< VatId, - { worker: NodeWorker; stream: DuplexStream } + { worker: NodeWorker; stream: DuplexStream } >(); /** @@ -47,19 +41,19 @@ export class NodejsVatWorkerManager implements VatWorkerManager { async launch( vatId: VatId, - ): Promise> { + ): Promise> { this.#logger.debug('launching vat', vatId); const { promise, resolve, reject } = - makePromiseKit>(); + makePromiseKit>(); const worker = new NodeWorker(this.#workerFilePath, { env: { NODE_VAT_ID: vatId, }, }); worker.once('online', () => { - const stream = new NodeWorkerDuplexStream( + const stream = new NodeWorkerDuplexStream( worker, - isVatCommandReply, + isJsonRpcMessage, ); this.workers.set(vatId, { worker, stream }); stream diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 646813a93..2ec6d59a4 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,4 +1,4 @@ -import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { Kernel } from '@ocap/kernel'; import { makeSQLKernelDatabase } from '@ocap/store/sqlite/nodejs'; import { NodeWorkerDuplexStream } from '@ocap/streams'; @@ -28,8 +28,8 @@ export async function makeKernel({ dbFilename?: string; }): Promise { const nodeStream = new NodeWorkerDuplexStream< - KernelCommand, - KernelCommandReply + JsonRpcRequest, + JsonRpcResponse >(port); const vatWorkerClient = new NodejsVatWorkerManager({ workerFilePath }); diff --git a/packages/nodejs/src/vat/streams.test.ts b/packages/nodejs/src/vat/streams.test.ts index 4711c3628..0fb53b1b0 100644 --- a/packages/nodejs/src/vat/streams.test.ts +++ b/packages/nodejs/src/vat/streams.test.ts @@ -9,10 +9,6 @@ const doMockParentPort = (value: unknown): void => { vi.resetModules(); }; -vi.mock('@ocap/kernel', async () => ({ - isVatCommand: vi.fn(() => true), -})); - vi.mock('@ocap/streams', () => ({ NodeWorkerDuplexStream: vi.fn(), })); @@ -37,13 +33,13 @@ describe('getPort', () => { }); }); -describe('makeCommandStream', () => { +describe('makeKernelStream', () => { it('returns a NodeWorkerDuplexStream', async () => { doMockParentPort(new MessageChannel().port1); const { NodeWorkerDuplexStream } = await import('@ocap/streams'); - const { makeCommandStream } = await import('./streams.ts'); - const commandStream = makeCommandStream(); - expect(commandStream).toBeInstanceOf(NodeWorkerDuplexStream); + const { makeKernelStream } = await import('./streams.ts'); + const kernelStream = makeKernelStream(); + expect(kernelStream).toBeInstanceOf(NodeWorkerDuplexStream); }); }); diff --git a/packages/nodejs/src/vat/streams.ts b/packages/nodejs/src/vat/streams.ts index b3475c356..9d6c77828 100644 --- a/packages/nodejs/src/vat/streams.ts +++ b/packages/nodejs/src/vat/streams.ts @@ -1,6 +1,6 @@ -import { isVatCommand } from '@ocap/kernel'; -import type { VatCommand, VatCommandReply } from '@ocap/kernel'; import { NodeWorkerDuplexStream } from '@ocap/streams'; +import { isJsonRpcMessage } from '@ocap/utils'; +import type { JsonRpcMessage } from '@ocap/utils'; import { parentPort } from 'node:worker_threads'; import type { MessagePort as NodePort } from 'node:worker_threads'; @@ -23,12 +23,12 @@ export function getPort(): NodePort { * * @returns A NodeWorkerDuplexStream */ -export function makeCommandStream(): NodeWorkerDuplexStream< - VatCommand, - VatCommandReply +export function makeKernelStream(): NodeWorkerDuplexStream< + JsonRpcMessage, + JsonRpcMessage > { - return new NodeWorkerDuplexStream( + return new NodeWorkerDuplexStream( getPort(), - isVatCommand, + isJsonRpcMessage, ); } diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 7d88f9fa1..a5828e6ae 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -6,7 +6,7 @@ import { Logger } from '@ocap/utils'; import fs from 'node:fs/promises'; import url from 'node:url'; -import { makeCommandStream } from './streams.ts'; +import { makeKernelStream } from './streams.ts'; const vatId = process.env.NODE_VAT_ID as VatId; const processLogger = new Logger('nodejs-vat-worker'); @@ -39,17 +39,17 @@ async function fetchBlob(blobURL: string): Promise { } /** - * The main function for the iframe. + * The main function for the vat worker. * * @param _logger - The logger to use for logging. (currently unused) */ async function main(_logger: Logger): Promise { - const commandStream = makeCommandStream(); - await commandStream.synchronize(); + const kernelStream = makeKernelStream(); + await kernelStream.synchronize(); // eslint-disable-next-line no-void void new VatSupervisor({ id: vatId, - commandStream, + kernelStream, fetchBlob, }); } diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 81b6587f9..59e09f8e8 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,6 +1,6 @@ import '@ocap/shims/endoify'; -import { Kernel, VatCommandMethod } from '@ocap/kernel'; +import { Kernel } from '@ocap/kernel'; import type { VatConfig, VatId } from '@ocap/kernel'; import { MessageChannel as NodeMessageChannel, @@ -77,7 +77,7 @@ describe('Kernel Worker', () => { testVatIds.map( async (vatId: VatId) => await kernel.sendVatCommand(vatId, { - method: VatCommandMethod.ping, + method: 'ping', params: [], }), ), diff --git a/packages/nodejs/test/workers/stream-sync.js b/packages/nodejs/test/workers/stream-sync.js index 8cb0d4d01..9a8f74e5b 100644 --- a/packages/nodejs/test/workers/stream-sync.js +++ b/packages/nodejs/test/workers/stream-sync.js @@ -1,5 +1,5 @@ import '../../dist/env/endoify.mjs'; -import { makeCommandStream } from '../../dist/vat/streams.mjs'; +import { makeKernelStream } from '../../dist/vat/streams.mjs'; main().catch(console.error); @@ -8,6 +8,6 @@ main().catch(console.error); * No supervisor is created, but the stream is synchronized for comms testing. */ async function main() { - const stream = makeCommandStream(); + const stream = makeKernelStream(); await stream.synchronize(); } diff --git a/packages/nodejs/tsconfig.json b/packages/nodejs/tsconfig.json index e976880f0..01ce9bbfd 100644 --- a/packages/nodejs/tsconfig.json +++ b/packages/nodejs/tsconfig.json @@ -22,7 +22,7 @@ "./src/**/*-trusted-prelude.js", "./test/**/*.ts", "./vitest.config.ts", - "./vitest.config.e2e.ts", - "./test/workers/*.js" - ] + "./vitest.config.e2e.ts" + ], + "exclude": ["./test/workers/*.js"] } diff --git a/packages/rpc-methods/src/RpcClient.test.ts b/packages/rpc-methods/src/RpcClient.test.ts index f9ef9f96d..81d8ef829 100644 --- a/packages/rpc-methods/src/RpcClient.test.ts +++ b/packages/rpc-methods/src/RpcClient.test.ts @@ -85,12 +85,12 @@ describe('RpcClient', () => { }); describe('handleResponse', () => { - it('should log an error if the message id is not found', () => { + it('calls logger.debug if the message id is not found', () => { const logger = makeLogger('[test]'); const client = new RpcClient(getMethods(), vi.fn(), 'test', logger); - const logError = vi.spyOn(logger, 'error'); + const logDebug = vi.spyOn(logger, 'debug'); client.handleResponse('test1', 'test'); - expect(logError).toHaveBeenCalledWith( + expect(logDebug).toHaveBeenCalledWith( 'Received response with unexpected id "test1".', ); }); diff --git a/packages/rpc-methods/src/RpcClient.ts b/packages/rpc-methods/src/RpcClient.ts index 0128611c4..e9835bf88 100644 --- a/packages/rpc-methods/src/RpcClient.ts +++ b/packages/rpc-methods/src/RpcClient.ts @@ -127,7 +127,7 @@ export class RpcClient< handleResponse(messageId: string, response: unknown): void { const requestCallbacks = this.#unresolvedMessages.get(messageId); if (requestCallbacks === undefined) { - this.#logger.error( + this.#logger.debug( `Received response with unexpected id "${messageId}".`, ); } else { diff --git a/packages/rpc-methods/src/types.ts b/packages/rpc-methods/src/types.ts index 1d924627a..6a020e09f 100644 --- a/packages/rpc-methods/src/types.ts +++ b/packages/rpc-methods/src/types.ts @@ -6,17 +6,17 @@ import type { JsonRpcParams, Json } from '@metamask/utils'; export type MethodSignature< Method extends string, Params extends JsonRpcParams, - Result extends Json, -> = (method: Method, params: Params) => Promise; + Result extends Json | Promise, +> = (method: Method, params: Params) => Result; export type MethodSpec< Method extends string, Params extends JsonRpcParams, - Result extends Json, + Result extends Json | Promise, > = { method: Method; params: Struct; - result: Struct; + result: Struct>; }; // `any` can safely be used in constraints. @@ -29,12 +29,13 @@ export type MethodSpecRecord = { type SpecRecordConstraint = MethodSpecRecord; -export type ExtractMethodSignature = Spec extends ( - method: infer Method extends string, - params: infer Params extends JsonRpcParams, -) => Promise - ? MethodSignature - : never; +export type ExtractMethodSignature = + Spec extends (( + method: infer Method extends string, + params: infer Params extends JsonRpcParams, + ) => infer Result extends Json | Promise) + ? MethodSignature + : never; export type ExtractMethodSpec< Specs extends SpecRecordConstraint, @@ -52,20 +53,20 @@ export type ExtractParams< export type ExtractResult< Method extends string, Specs extends SpecRecordConstraint, -> = Infer['result']>; +> = UnwrapPromise['result']>>; export type HandlerFunction< Params extends JsonRpcParams, - Result extends Json, + Result extends Json | Promise, Hooks extends Record, -> = (hooks: Hooks, params: Params) => Promise; +> = (hooks: Hooks, params: Params) => Result; // Service-side types export type Handler< Method extends string, Params extends JsonRpcParams, - Result extends Json, + Result extends Json | Promise, Hooks extends Record, > = MethodSpec & { hooks: { [Key in keyof Hooks]: true }; @@ -79,3 +80,15 @@ type HandlerConstraint = Handler; export type HandlerRecord = { [Key in Handlers['method']]: Extract; }; + +// Utils + +// eslint-disable-next-line @typescript-eslint/naming-convention +type UnwrapPromise = T extends Promise ? U : T; + +export type MethodRequest = { + id: string | number | null; + jsonrpc: '2.0'; + method: Method['method']; + params: Infer; +}; diff --git a/packages/rpc-methods/test/methods.ts b/packages/rpc-methods/test/methods.ts index 7c684ce66..178deb995 100644 --- a/packages/rpc-methods/test/methods.ts +++ b/packages/rpc-methods/test/methods.ts @@ -17,7 +17,7 @@ export const getMethods = () => method: 'method1', params: tuple([string()]), result: literal(null), - } as MethodSpec<'method1', [string], null>, + } as MethodSpec<'method1', [string], Promise>, method2: { method: 'method2', params: tuple([number()]), @@ -35,11 +35,16 @@ export const getHandlers = () => { hooks.hook1(); return null; }, - } as Handler<'method1', [string], null, Pick>, + } as Handler< + 'method1', + [string], + Promise, + Pick + >, method2: { ...methods.method2, hooks: { hook3: true } as const, - implementation: async (hooks, [value]) => { + implementation: (hooks, [value]) => { hooks.hook3(); return value * 2; }, diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index b2bbc9c8c..1934bf819 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1 +1,7 @@ -export type { KVStore, VatStore, KernelDatabase } from './types.ts'; +export type { + KVStore, + VatStore, + KernelDatabase, + VatKVStore, + VatCheckpoint, +} from './types.ts'; diff --git a/packages/store/src/sqlite/nodejs.test.ts b/packages/store/src/sqlite/nodejs.test.ts index 0622fb52b..a3727e423 100644 --- a/packages/store/src/sqlite/nodejs.test.ts +++ b/packages/store/src/sqlite/nodejs.test.ts @@ -118,17 +118,17 @@ describe('makeSQLKernelDatabase', () => { ]); }); - it('vatStore.getKVData returns a map of the data', async () => { + it('vatStore.getKVData returns the data', async () => { const db = await makeSQLKernelDatabase({}); const vatStore = db.makeVatStore('vvat'); const data = vatStore.getKVData(); - expect(data).toStrictEqual(new Map(mockKVDataForMap)); + expect(data).toStrictEqual([...mockKVDataForMap]); }); it('vatStore.updateKVData updates the database', async () => { const db = await makeSQLKernelDatabase({}); const vatStore = db.makeVatStore('vvat'); - vatStore.updateKVData(new Map(mockKVDataForMap), new Set(['del1', 'del2'])); + vatStore.updateKVData([...mockKVDataForMap], ['del1', 'del2']); expect(mockStatement.run).toHaveBeenCalled(); // begin transaction expect(mockStatement.run).toHaveBeenCalledWith('vvat', 'key1', 'value1'); // set expect(mockStatement.run).toHaveBeenCalledWith('vvat', 'key2', 'value2'); // set diff --git a/packages/store/src/sqlite/nodejs.ts b/packages/store/src/sqlite/nodejs.ts index b8909198b..f5c699622 100644 --- a/packages/store/src/sqlite/nodejs.ts +++ b/packages/store/src/sqlite/nodejs.ts @@ -177,15 +177,15 @@ export async function makeSQLKernelDatabase({ * * @returns the vatstore contents as a key-value Map. */ - function getKVData(): Map { - const result = new Map(); + function getKVData(): [string, string][] { + const result: [string, string][] = []; type KVPair = { key: string; value: string; }; for (const kvPair of sqlVatstoreGetAll.iterate(vatID)) { const { key, value } = kvPair as KVPair; - result.set(key, value); + result.push([key, value]); } return result; } @@ -196,15 +196,12 @@ export async function makeSQLKernelDatabase({ * @param sets - A map of key values that have been changed. * @param deletes - A set of keys that have been deleted. */ - function updateKVData( - sets: Map, - deletes: Set, - ): void { + function updateKVData(sets: [string, string][], deletes: string[]): void { db.transaction(() => { - for (const [key, value] of sets.entries()) { + for (const [key, value] of sets) { sqlVatstoreSet.run(vatID, key, value); } - for (const value of deletes.values()) { + for (const value of deletes) { sqlVatstoreDelete.run(vatID, value); } })(); diff --git a/packages/store/src/sqlite/wasm.test.ts b/packages/store/src/sqlite/wasm.test.ts index d7c131172..69a828d12 100644 --- a/packages/store/src/sqlite/wasm.test.ts +++ b/packages/store/src/sqlite/wasm.test.ts @@ -157,13 +157,13 @@ describe('makeSQLKernelDatabase', () => { .mockReturnValueOnce(mockKVData[1].key) .mockReturnValueOnce(mockKVData[1].value); const data = vatStore.getKVData(); - expect(data).toStrictEqual(new Map(mockKVDataForMap)); + expect(data).toStrictEqual([...mockKVDataForMap]); }); it('vatStore.updateKVData updates the database', async () => { const db = await makeSQLKernelDatabase({}); const vatStore = db.makeVatStore('vvat'); - vatStore.updateKVData(new Map(mockKVDataForMap), new Set(['del1', 'del2'])); + vatStore.updateKVData([...mockKVDataForMap], ['del1', 'del2']); // begin transaction expect(mockStatement.step).toHaveBeenCalled(); expect(mockStatement.reset).toHaveBeenCalled(); diff --git a/packages/store/src/sqlite/wasm.ts b/packages/store/src/sqlite/wasm.ts index dc5da9ae3..b614e75d4 100644 --- a/packages/store/src/sqlite/wasm.ts +++ b/packages/store/src/sqlite/wasm.ts @@ -239,14 +239,14 @@ export async function makeSQLKernelDatabase({ * * @returns the vatstore contents as a key-value Map. */ - function getKVData(): Map { - const result = new Map(); + function getKVData(): [string, string][] { + const result: [string, string][] = []; sqlVatstoreGetAll.bind([vatID]); try { while (sqlVatstoreGetAll.step()) { const key = sqlVatstoreGetAll.getString(0) as string; const value = sqlVatstoreGetAll.getString(1) as string; - result.set(key, value); + result.push([key, value]); } } finally { sqlVatstoreGetAll.reset(); @@ -260,19 +260,16 @@ export async function makeSQLKernelDatabase({ * @param sets - A map of key values that have been changed. * @param deletes - A set of keys that have been deleted. */ - function updateKVData( - sets: Map, - deletes: Set, - ): void { + function updateKVData(sets: [string, string][], deletes: string[]): void { try { sqlBeginTransaction.step(); sqlBeginTransaction.reset(); - for (const [key, value] of sets.entries()) { + for (const [key, value] of sets) { sqlVatstoreSet.bind([vatID, key, value]); sqlVatstoreSet.step(); sqlVatstoreSet.reset(); } - for (const value of deletes.values()) { + for (const value of deletes) { sqlVatstoreDelete.bind([vatID, value]); sqlVatstoreDelete.step(); sqlVatstoreDelete.reset(); diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 204189f35..64fd0c6e7 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -6,9 +6,22 @@ export type KVStore = { delete(key: string): void; }; +export type KVPair = [string, string]; + +/** + * A vat checkpoint is a tuple of two arrays describing the changes since the previous checkpoint: + * - The first array contains updated key-value pairs. + * - The second array contains deleted keys. + */ +export type VatCheckpoint = [KVPair[], string[]]; + +export type VatKVStore = KVStore & { + checkpoint(): VatCheckpoint; +}; + export type VatStore = { - getKVData(): Map; - updateKVData(sets: Map, deletes: Set): void; + getKVData(): KVPair[]; + updateKVData(sets: KVPair[], deletes: string[]): void; }; export type KernelDatabase = { diff --git a/packages/test-utils/src/env/mock-kernel.ts b/packages/test-utils/src/env/mock-kernel.ts index 3b1994389..2b453e64d 100644 --- a/packages/test-utils/src/env/mock-kernel.ts +++ b/packages/test-utils/src/env/mock-kernel.ts @@ -13,7 +13,6 @@ type ResetMocks = () => void; type SetMockBehavior = (options: { isVatConfig?: boolean; isVatId?: boolean; - isKernelCommand?: boolean; }) => void; export const setupOcapKernelMock = (): { @@ -22,14 +21,12 @@ export const setupOcapKernelMock = (): { } => { let isVatConfigMock = true; let isVatIdMock = true; - let isKernelCommandMock = true; // Mock implementation vi.doMock('@ocap/kernel', () => { const VatIdStruct = define('VatId', () => isVatIdMock); const VatConfigStruct = define('VatConfig', () => isVatConfigMock); return { - isKernelCommand: () => isKernelCommandMock, isVatId: () => isVatIdMock, isVatConfig: () => isVatConfigMock, VatIdStruct, @@ -47,11 +44,6 @@ export const setupOcapKernelMock = (): { params: literal(null), }), }), - isVatCommandReply: vi.fn(() => true), - VatCommandMethod: { - ping: 'ping', - }, - KernelCommandMethod: {}, VatWorkerServiceCommandMethod: { launch: 'launch', terminate: 'terminate', @@ -64,12 +56,10 @@ export const setupOcapKernelMock = (): { resetMocks: (): void => { isVatConfigMock = true; isVatIdMock = true; - isKernelCommandMock = true; }, setMockBehavior: (options: { isVatConfig?: boolean; isVatId?: boolean; - isKernelCommand?: boolean; }): void => { if (typeof options.isVatConfig === 'boolean') { isVatConfigMock = options.isVatConfig; @@ -77,9 +67,6 @@ export const setupOcapKernelMock = (): { if (typeof options.isVatId === 'boolean') { isVatIdMock = options.isVatId; } - if (typeof options.isKernelCommand === 'boolean') { - isKernelCommandMock = options.isKernelCommand; - } }, }; }; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3ced8abe6..f8d18bcf8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,12 +1,18 @@ export { Logger, makeLogger } from './logger.ts'; export { delay, makeCounter } from './misc.ts'; export { stringify } from './stringify.ts'; -export type { ExtractGuardType, PromiseCallbacks, TypeGuard } from './types.ts'; +export type { + ExtractGuardType, + JsonRpcMessage, + PromiseCallbacks, + TypeGuard, +} from './types.ts'; export { EmptyJsonArray, isPrimitive, isTypedArray, isTypedObject, + isJsonRpcMessage, } from './types.ts'; export { fetchValidatedJson } from './fetchValidatedJson.ts'; export { waitUntilQuiescent } from './wait-quiescent.ts'; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index f8aa579c7..bf3d88196 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -1,8 +1,14 @@ import type { Primitive } from '@endo/captp'; import type { PromiseKit } from '@endo/promise-kit'; -import type { Infer } from '@metamask/superstruct'; -import { array, empty } from '@metamask/superstruct'; -import { isObject, UnsafeJsonStruct } from '@metamask/utils'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { array, empty, is, union } from '@metamask/superstruct'; +import { + isObject, + UnsafeJsonStruct, + JsonRpcRequestStruct, + JsonRpcResponseStruct, +} from '@metamask/utils'; +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; export type TypeGuard = (value: unknown) => value is Type; @@ -47,3 +53,13 @@ export type PromiseCallbacks = Omit< export const EmptyJsonArray = empty(array(UnsafeJsonStruct)); export type EmptyJsonArray = Infer; + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse; + +export const JsonRpcMessageStruct: Struct = union([ + JsonRpcRequestStruct, + JsonRpcResponseStruct, +]); + +export const isJsonRpcMessage = (value: unknown): value is JsonRpcMessage => + is(value, JsonRpcMessageStruct); diff --git a/vitest.config.ts b/vitest.config.ts index 0214195fe..eeaae42b5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,16 +82,16 @@ export default defineConfig({ lines: 98.63, }, 'packages/extension/**': { - statements: 77.94, - functions: 80.31, - branches: 75, - lines: 77.96, + statements: 78.71, + functions: 80.72, + branches: 74.82, + lines: 78.7, }, 'packages/kernel/**': { - statements: 88.94, - functions: 93.44, - branches: 77.41, - lines: 88.91, + statements: 89.31, + functions: 91.86, + branches: 77.11, + lines: 89.28, }, 'packages/nodejs/**': { statements: 72.91, diff --git a/yarn.lock b/yarn.lock index 3033fb7b5..c3c4dbfa1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2031,6 +2031,7 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/utils": "npm:^11.4.0" "@ocap/cli": "workspace:^" "@ocap/kernel": "workspace:^" "@ocap/nodejs": "workspace:^" @@ -2053,7 +2054,6 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" - fast-deep-equal: "npm:^3.1.3" jsdom: "npm:^26.0.0" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" @@ -2080,6 +2080,7 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.4.0" "@ocap/cli": "workspace:^" @@ -2179,6 +2180,7 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/utils": "npm:^11.4.0" "@ocap/cli": "workspace:^" "@ocap/kernel": "workspace:^" "@ocap/shims": "workspace:^"