diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index d33ea67618..33d6cf6951 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -107,6 +107,7 @@ jobs: cd node_modules/better-sqlite3 yarn build-release cd - + - run: npm rebuild @ipshipyard/node-datachannel - run: yarn build - run: yarn test:ci - name: Require clean working directory @@ -157,6 +158,7 @@ jobs: cd node_modules/better-sqlite3 yarn build-release cd - + - run: npm rebuild @ipshipyard/node-datachannel - run: VITE_DB_FOLDER=e2e yarn build - name: Run E2E tests id: e2e diff --git a/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch b/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch new file mode 100644 index 0000000000..e39ec3aeba --- /dev/null +++ b/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch @@ -0,0 +1,41 @@ +diff --git a/dist/src/decode.js b/dist/src/decode.js +index b2906243c57e0fe838e4510cd4b53d36b6e17ebf..2bfe711cde98de5a0ba4aaea75861f7389c4307b 100644 +--- a/dist/src/decode.js ++++ b/dist/src/decode.js +@@ -112,14 +112,14 @@ export class Decoder { + export function returnlessSource(source) { + if (source[Symbol.iterator] !== undefined) { + const iterator = source[Symbol.iterator](); +- iterator.return = undefined; ++ Object.defineProperty(iterator, 'return', {}); + return { + [Symbol.iterator]() { return iterator; } + }; + } + else if (source[Symbol.asyncIterator] !== undefined) { + const iterator = source[Symbol.asyncIterator](); +- iterator.return = undefined; ++ Object.defineProperty(iterator, 'return', {}); + return { + [Symbol.asyncIterator]() { return iterator; } + }; +diff --git a/src/decode.ts b/src/decode.ts +index 56440b95afba0330d5ece77a2f737c79a80155b2..2429fa8cf3206389b9a4b5ae395c8d402872eae9 100644 +--- a/src/decode.ts ++++ b/src/decode.ts +@@ -128,13 +128,13 @@ export class Decoder { + export function returnlessSource (source: Source): Source { + if ((source as Iterable)[Symbol.iterator] !== undefined) { + const iterator = (source as Iterable)[Symbol.iterator]() +- iterator.return = undefined ++ Object.defineProperty(iterator, 'return', {}); + return { + [Symbol.iterator] () { return iterator } + } + } else if ((source as AsyncIterable)[Symbol.asyncIterator] !== undefined) { + const iterator = (source as AsyncIterable)[Symbol.asyncIterator]() +- iterator.return = undefined ++ Object.defineProperty(iterator, 'return', {}); + return { + [Symbol.asyncIterator] () { return iterator } + } diff --git a/packages/brow-2-brow/package.json b/packages/brow-2-brow/package.json index ed7cc20606..b9d4ee5ac6 100644 --- a/packages/brow-2-brow/package.json +++ b/packages/brow-2-brow/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@chainsafe/libp2p-noise": "^16.1.3", - "@chainsafe/libp2p-yamux": "^6.0.2", + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch", "@libp2p/autonat": "^2.0.30", "@libp2p/bootstrap": "^11.0.35", "@libp2p/circuit-relay-v2": "^3.2.11", diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index d849a8a2c8..dd4659a57b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,6 @@ import { makeIframeVatWorker, - VatWorkerServer, + PlatformServicesServer, } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcCall } from '@metamask/kernel-utils'; import type { JsonRpcCall } from '@metamask/kernel-utils'; @@ -62,7 +62,7 @@ async function makeKernelWorker(): Promise< JsonRpcCall >(port, isJsonRpcResponse); - await VatWorkerServer.make(worker as PostMessageTarget, (vatId) => + await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) => makeIframeVatWorker({ id: vatId, iframeUri: 'iframe.html', diff --git a/packages/extension/src/vats/sample-vat.js b/packages/extension/src/vats/sample-vat.js index 6223eb8622..3579328d20 100644 --- a/packages/extension/src/vats/sample-vat.js +++ b/packages/extension/src/vats/sample-vat.js @@ -12,10 +12,20 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; export function buildRootObject(_vatPowers, parameters, _baggage) { const name = parameters?.name ?? 'anonymous'; console.log(`buildRootObject "${name}"`); + let redeemer; return makeDefaultExo('root', { - async bootstrap(vats) { + async bootstrap(vats, services) { console.log(`vat ${name} is bootstrap`); + const issuer = services.ocapURLIssuerService; + redeemer = services.ocapURLRedemptionService; + console.log(`in bootstrap redeemer=${redeemer}`); + if (issuer) { + const url = await E(issuer).issue(vats.bob); + console.log(`url for bob: ${url}`); + } else { + console.log(`no ocapURLIssuerService found`); + } const pb = E(vats.bob).hello(name); const pc = E(vats.carol).hello(name); console.log(`vat ${name} got "hello" answer from Bob: '${await pb}'`); @@ -26,5 +36,15 @@ export function buildRootObject(_vatPowers, parameters, _baggage) { console.log(message); return message; }, + async doRunRun(url) { + console.log(`in doRunRun redeemer=${redeemer}`); + if (redeemer) { + const remote = await E(redeemer).redeem(url); + console.log(`redeemed ${url} successfully (?)`); + await E(remote).hello(`remote ${name}`); + } else { + console.log('no ocapURLRedemptionService found'); + } + }, }); } diff --git a/packages/extension/test/e2e/control-panel.test.ts b/packages/extension/test/e2e/control-panel.test.ts index ae2b1544d5..2696e2a704 100644 --- a/packages/extension/test/e2e/control-panel.test.ts +++ b/packages/extension/test/e2e/control-panel.test.ts @@ -162,33 +162,6 @@ test.describe('Control Panel', () => { popupPage.locator('[data-testid="message-output"]'), ).toContainText('State cleared'); await expect(popupPage.locator('table')).not.toBeVisible(); - // ensure kernel state was cleared - await popupPage.locator('[data-testid="clear-logs-button"]').click(); - await expect( - popupPage.locator('[data-testid="message-output"]'), - ).toContainText(''); - await popupPage.click('button:text("Database Inspector")'); - const expectedValues = JSON.stringify([ - { key: 'queue.run.head', value: '1' }, - { key: 'queue.run.tail', value: '1' }, - { key: 'gcActions', value: '[]' }, - { key: 'reapQueue', value: '[]' }, - { key: 'vats.terminated', value: '[]' }, - { key: 'nextObjectId', value: '1' }, - { key: 'nextPromiseId', value: '1' }, - { key: 'nextVatId', value: '1' }, - { key: 'nextRemoteId', value: '1' }, - { key: 'subclusters', value: '[]' }, - { key: 'nextSubclusterId', value: '1' }, - { key: 'vatToSubclusterMap', value: '{}' }, - ]); - await expect( - popupPage.locator('[data-testid="message-output"]'), - ).toContainText(expectedValues); - await expect( - popupPage.locator('[data-testid="message-output"]'), - ).not.toContainText('"initialized":true'); - await popupPage.click('button:text("Control Panel")'); }); test('should reload kernel state and load default vats', async () => { @@ -218,18 +191,19 @@ test.describe('Control Panel', () => { const v3Values = [ '{"key":"e.nextPromiseId.v3","value":"2"}', '{"key":"e.nextObjectId.v3","value":"1"}', - '{"key":"ko3.owner","value":"v3"}', - '{"key":"v3.c.ko3","value":"R o+0"}', - '{"key":"v3.c.o+0","value":"ko3"}', - '{"key":"v3.c.kp3","value":"R p-1"}', - '{"key":"v3.c.p-1","value":"kp3"}', - '{"key":"ko3.refCount","value":"1,1"}', + '{"key":"ko5.owner","value":"v3"}', + '{"key":"v3.c.ko5","value":"R o+0"}', + '{"key":"v3.c.o+0","value":"ko5"}', + '{"key":"v3.c.kp4","value":"R p-1"}', + '{"key":"v3.c.p-1","value":"kp4"}', + '{"key":"ko5.refCount","value":"1,1"}', + '{"key":"kp4.refCount","value":"2"}', ]; - const v1ko3Values = [ - '{"key":"v1.c.ko3","value":"R o-2"}', - '{"key":"v1.c.o-2","value":"ko3"}', - '{"key":"kp3.state","value":"fulfilled"}', - '{"key":"kp3.value","value"', + const v1koValues = [ + '{"key":"v1.c.ko4","value":"R o-1"}', + '{"key":"v1.c.o-1","value":"ko4"}', + '{"key":"v1.c.ko5","value":"R o-2"}', + '{"key":"v1.c.o-2","value":"ko5"}', ]; await expect( popupPage.locator('[data-testid="message-output"]'), @@ -242,7 +216,7 @@ test.describe('Control Panel', () => { popupPage.locator('[data-testid="message-output"]'), ).toContainText(value); } - for (const value of v1ko3Values) { + for (const value of v1koValues) { await expect( popupPage.locator('[data-testid="message-output"]'), ).toContainText(value); @@ -289,16 +263,16 @@ test.describe('Control Panel', () => { popupPage.locator('[data-testid="message-output"]'), ).not.toContainText(value); } - // ko3 reference still exists for v1 - for (const value of v1ko3Values) { + // ko3 (vat root) reference still exists for v1 + for (const value of v1koValues) { await expect( popupPage.locator('[data-testid="message-output"]'), ).toContainText(value); } - // kp3 reference dropped to 1 + // kp4 reference dropped to 1 await expect( popupPage.locator('[data-testid="message-output"]'), - ).toContainText('{"key":"kp3.refCount","value":"1"}'); + ).toContainText('{"key":"kp4.refCount","value":"1"}'); await popupPage.click('button:text("Control Panel")'); await popupPage.locator('[data-testid="accordion-header"]').first().click(); // delete v1 @@ -321,7 +295,7 @@ test.describe('Control Panel', () => { await expect( popupPage.locator('[data-testid="message-output"]'), ).toContainText('{"key":"vats.terminated","value":"[]"}'); - for (const value of v1ko3Values) { + for (const value of v1koValues) { await expect( popupPage.locator('[data-testid="message-output"]'), ).not.toContainText(value); diff --git a/packages/extension/test/e2e/object-registry.test.ts b/packages/extension/test/e2e/object-registry.test.ts index 6be202db15..bd4809cfa5 100644 --- a/packages/extension/test/e2e/object-registry.test.ts +++ b/packages/extension/test/e2e/object-registry.test.ts @@ -73,7 +73,7 @@ test.describe('Object Registry', () => { await clearLogsButton.click(); await popupPage.click('button:text("Object Registry")'); await expect( - popupPage.locator('text=Alice (v1) - 3 objects, 3 promises'), + popupPage.locator('text=Alice (v1) - 5 objects, 4 promises'), ).toBeVisible(); const targetSelect = popupPage.locator('[data-testid="message-target"]'); await expect(targetSelect).toBeVisible(); @@ -92,7 +92,7 @@ test.describe('Object Registry', () => { ); await expect(messageResponse).toBeVisible(); await expect(messageResponse).toContainText( - '"body":"#[\\"__getInterfaceGuard__\\",\\"__getMethodNames__\\",\\"bootstrap\\",\\"hello\\"]"', + '"body":"#[\\"__getInterfaceGuard__\\",\\"__getMethodNames__\\",\\"bootstrap\\",\\"doRunRun\\",\\"hello\\"]"', ); await expect(messageResponse).toContainText('"slots":[]'); await clearLogsButton.click(); @@ -102,13 +102,14 @@ test.describe('Object Registry', () => { await expect(messageResponse).toContainText('"body":"#\\"vat Alice got'); await expect(messageResponse).toContainText('"slots":['); await expect( - popupPage.locator('text=Alice (v1) - 3 objects, 5 promises'), + popupPage.locator('text=Alice (v1) - 5 objects, 6 promises'), ).toBeVisible(); }); test('should revoke an object', async () => { const owner = 'v1'; - const [target, method, params] = ['ko1', 'hello', '["Bob"]']; + const v1Root = 'ko3'; + const [target, method, params] = [v1Root, 'hello', '["Bob"]']; // Before revoking, we should be able to send a message to the object let response = await sendMessage(popupPage, target, method, params); diff --git a/packages/kernel-browser-runtime/src/VatWorkerClient.test.ts b/packages/kernel-browser-runtime/src/PlatformServicesClient.test.ts similarity index 87% rename from packages/kernel-browser-runtime/src/VatWorkerClient.test.ts rename to packages/kernel-browser-runtime/src/PlatformServicesClient.test.ts index 8631c27df1..5d004abf08 100644 --- a/packages/kernel-browser-runtime/src/VatWorkerClient.test.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesClient.test.ts @@ -7,8 +7,8 @@ import { makeMockMessageTarget } from '@ocap/test-utils'; import { TestDuplexStream } from '@ocap/test-utils/streams'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import type { VatWorkerClientStream } from './VatWorkerClient.ts'; -import { VatWorkerClient } from './VatWorkerClient.ts'; +import type { PlatformServicesClientStream } from './PlatformServicesClient.ts'; +import { PlatformServicesClient } from './PlatformServicesClient.ts'; vi.mock('@metamask/streams/browser', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-shadow @@ -54,34 +54,33 @@ const makeNullReply = (messageId: `m${number}`): MessageEvent => result: null, }); -describe('VatWorkerClient', () => { +describe('PlatformServicesClient', () => { it('constructs with default logger', async () => { const stream = await TestDuplexStream.make(() => undefined); await stream.synchronize(); - const client = new VatWorkerClient( - stream as unknown as VatWorkerClientStream, + const client = new PlatformServicesClient( + stream as unknown as PlatformServicesClientStream, ); expect(client).toBeDefined(); }); it('constructs using static factory method', async () => { - const mockMessageTarget = makeMockMessageTarget(); - const client = await VatWorkerClient.make(mockMessageTarget); + const client = await PlatformServicesClient.make(makeMockMessageTarget()); expect(client).toBeDefined(); - expect(client).toBeInstanceOf(VatWorkerClient); + expect(client).toBeInstanceOf(PlatformServicesClient); }); describe('message handling', () => { let stream: TestDuplexStream; let clientLogger: Logger; - let client: VatWorkerClient; + let client: PlatformServicesClient; beforeEach(async () => { stream = await TestDuplexStream.make(() => undefined); await stream.synchronize(); clientLogger = new Logger('test-client'); - client = new VatWorkerClient( - stream as unknown as VatWorkerClientStream, + client = new PlatformServicesClient( + stream as unknown as PlatformServicesClientStream, clientLogger, ); }); @@ -139,8 +138,8 @@ describe('VatWorkerClient', () => { it('can be called before client is started', async () => { const newStream = await TestDuplexStream.make(() => undefined); await newStream.synchronize(); - const newClient = new VatWorkerClient( - newStream as unknown as VatWorkerClientStream, + const newClient = new PlatformServicesClient( + newStream as unknown as PlatformServicesClientStream, ); // Call launch before starting the client diff --git a/packages/kernel-browser-runtime/src/PlatformServicesClient.ts b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts new file mode 100644 index 0000000000..df884b8ab8 --- /dev/null +++ b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts @@ -0,0 +1,217 @@ +import { RpcClient, RpcService } from '@metamask/kernel-rpc-methods'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import { isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import type { + PlatformServices, + RemoteMessageHandler, + VatId, + VatConfig, +} from '@metamask/ocap-kernel'; +import { + platformServicesMethodSpecs, + kernelRemoteHandlers, +} from '@metamask/ocap-kernel/rpc'; +import { serializeError } from '@metamask/rpc-errors'; +import type { DuplexStream } from '@metamask/streams'; +import { + MessagePortDuplexStream, + PostMessageDuplexStream, +} from '@metamask/streams/browser'; +import type { + PostMessageEnvelope, + PostMessageTarget, +} from '@metamask/streams/browser'; +import { isJsonRpcResponse, isJsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcId } from '@metamask/utils'; + +// Appears in the docs. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { PlatformServicesServer } from './PlatformServicesServer.ts'; + +export type PlatformServicesClientStream = PostMessageDuplexStream< + MessageEvent, + PostMessageEnvelope +>; + +export class PlatformServicesClient implements PlatformServices { + readonly #logger: Logger; + + readonly #stream: PlatformServicesClientStream; + + readonly #rpcClient: RpcClient; + + readonly #rpcServer: RpcService; + + readonly #portMap: Map; + + #remoteMessageHandler: RemoteMessageHandler | undefined = undefined; + + /** + * **ATTN:** Prefer {@link PlatformServicesClient.make} over constructing + * this class directly. + * + * The client end of the platform services, intended to be constructed in + * the kernel worker. Sends launch and terminate worker requests to the + * server and wraps the launch response in a DuplexStream for consumption + * by the kernel, and provides network connectivity. + * + * @see {@link PlatformServicesServer} for the other end of the service. + * + * @param stream - The stream to use for communication with the server. + * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[platform services client]'. + */ + constructor(stream: PlatformServicesClientStream, logger?: Logger) { + this.#stream = stream; + this.#portMap = new Map(); + this.#logger = logger ?? new Logger('platform-services-client'); + this.#rpcClient = new RpcClient( + platformServicesMethodSpecs, + async (request) => { + if ('id' in request) { + if (request.method === 'launch') { + this.#portMap.set(request.id, undefined); + } + } + await this.#sendMessage(request); + }, + 'm', + this.#logger, + ); + this.#rpcServer = new RpcService(kernelRemoteHandlers, { + remoteDeliver: this.#remoteDeliver.bind(this), + }); + + // Start draining messages immediately after construction + // This runs for the lifetime of the client + this.#stream.drain(this.#handleMessage.bind(this)).catch((error) => { + this.#logger.error('Error draining stream:', error); + }); + } + + /** + * Create and initialize a new {@link PlatformServicesClient}. + * The client will be ready to handle vat launches after this completes. + * + * @param messageTarget - The target to use for posting and receiving messages. + * @param logger - An optional {@link Logger}. + * @returns A new {@link PlatformServicesClient}. + */ + static async make( + messageTarget: PostMessageTarget, + logger?: Logger, + ): Promise { + const stream: PlatformServicesClientStream = new PostMessageDuplexStream({ + messageTarget, + messageEventMode: 'event', + validateInput: (message): message is MessageEvent => + message instanceof MessageEvent && isJsonRpcMessage(message.data), + }); + // Synchronize the stream before creating the client + await stream.synchronize(); + // Now create the client which will start draining immediately + return new PlatformServicesClient(stream, logger); + } + + async launch( + vatId: VatId, + vatConfig: VatConfig, + ): Promise> { + const [id] = await this.#rpcClient.callAndGetId('launch', { + vatId, + vatConfig, + }); + const port = this.#portMap.get(id); + if (!port) { + throw new Error( + `No port found for launch of: ${stringify({ vatId, vatConfig })}`, + ); + } + this.#portMap.delete(id); + return await MessagePortDuplexStream.make( + port, + isJsonRpcMessage, + ); + } + + async terminate(vatId: VatId): Promise { + await this.#rpcClient.call('terminate', { vatId }); + } + + async terminateAll(): Promise { + await this.#rpcClient.call('terminateAll', []); + } + + async initializeRemoteComms( + keySeed: string, + knownRelays: string[], + remoteMessageHandler: (from: string, message: string) => Promise, + ): Promise { + this.#remoteMessageHandler = remoteMessageHandler; + await this.#rpcClient.call('initializeRemoteComms', { + keySeed, + knownRelays, + }); + } + + async sendRemoteMessage(to: string, message: string): Promise { + await this.#rpcClient.call('sendRemoteMessage', { to, message }); + } + + async #remoteDeliver(from: string, message: string): Promise { + if (this.#remoteMessageHandler) { + return await this.#remoteMessageHandler(from, message); + } + throw Error(`remote message handler not set`); + } + + async #sendMessage(payload: JsonRpcMessage): Promise { + await this.#stream.write({ + payload, + transfer: [], + }); + } + + async #handleMessage(event: MessageEvent): Promise { + if (isJsonRpcResponse(event.data)) { + const { id } = event.data; + const port = event.ports.at(0); + if (typeof id !== 'string') { + this.#logger.error( + 'Received response with unexpected id:', + stringify(event.data), + ); + return; + } + + if (this.#portMap.has(id)) { + this.#portMap.set(id, port); + } else if (port !== undefined) { + this.#logger.error( + 'Received message with unexpected port:', + stringify(event.data), + ); + } + + this.#rpcClient.handleResponse(id, event.data); + } else if (isJsonRpcRequest(event.data)) { + const { id, method, params } = event.data; + try { + this.#rpcServer.assertHasMethod(method); + const result = await this.#rpcServer.execute(method, params); + await this.#sendMessage({ + id, + result, + jsonrpc: '2.0', + }); + } catch (error) { + await this.#sendMessage({ + id, + error: serializeError(error), + jsonrpc: '2.0', + }); + } + } + } +} +harden(PlatformServicesClient); diff --git a/packages/kernel-browser-runtime/src/VatWorkerServer.test.ts b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts similarity index 94% rename from packages/kernel-browser-runtime/src/VatWorkerServer.test.ts rename to packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts index 8354e734f7..dbd6913e26 100644 --- a/packages/kernel-browser-runtime/src/VatWorkerServer.test.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts @@ -12,11 +12,14 @@ import { TestDuplexStream } from '@ocap/test-utils/streams'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import type { Mock } from 'vitest'; -import { VatWorkerServer } from './VatWorkerServer.ts'; -import type { VatWorker, VatWorkerServiceStream } from './VatWorkerServer.ts'; +import { PlatformServicesServer } from './PlatformServicesServer.ts'; +import type { + VatWorker, + PlatformServicesStream, +} from './PlatformServicesServer.ts'; vi.mock('@metamask/ocap-kernel', () => ({ - VatWorkerServiceCommandMethod: { + PlatformServicesCommandMethod: { launch: 'launch', terminate: 'terminate', terminateAll: 'terminateAll', @@ -60,7 +63,7 @@ const makeTerminateAllMessageEvent = (messageId: `m${number}`): MessageEvent => params: [], }); -describe('VatWorkerServer', () => { +describe('PlatformServicesServer', () => { let cleanup: (() => Promise)[] = []; beforeEach(() => { @@ -81,15 +84,15 @@ describe('VatWorkerServer', () => { it('constructs with default logger', async () => { const stream = await TestDuplexStream.make(() => undefined); await stream.synchronize(); - const server = new VatWorkerServer( - stream as unknown as VatWorkerServiceStream, + const server = new PlatformServicesServer( + stream as unknown as PlatformServicesStream, () => ({}) as unknown as VatWorker, ); expect(server).toBeDefined(); }); it('constructs using static factory method', async () => { - const server = await VatWorkerServer.make( + const server = await PlatformServicesServer.make( makeMockMessageTarget(), () => ({}) as unknown as VatWorker, ); @@ -126,8 +129,8 @@ describe('VatWorkerServer', () => { stream = await TestDuplexStream.make(() => undefined); await stream.synchronize(); // eslint-disable-next-line no-new - new VatWorkerServer( - stream as unknown as VatWorkerServiceStream, + new PlatformServicesServer( + stream as unknown as PlatformServicesStream, makeMockVatWorker, logger, ); diff --git a/packages/kernel-browser-runtime/src/PlatformServicesServer.ts b/packages/kernel-browser-runtime/src/PlatformServicesServer.ts new file mode 100644 index 0000000000..52c3f9333f --- /dev/null +++ b/packages/kernel-browser-runtime/src/PlatformServicesServer.ts @@ -0,0 +1,230 @@ +import { + VatAlreadyExistsError, + VatNotFoundError, +} from '@metamask/kernel-errors'; +import { RpcClient, RpcService } from '@metamask/kernel-rpc-methods'; +import { isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import type { + VatId, + VatConfig, + SendRemoteMessage, +} from '@metamask/ocap-kernel'; +import { initNetwork } from '@metamask/ocap-kernel'; +import { + kernelRemoteMethodSpecs, + platformServicesHandlers, +} from '@metamask/ocap-kernel/rpc'; +import { serializeError } from '@metamask/rpc-errors'; +import { PostMessageDuplexStream } from '@metamask/streams/browser'; +import type { + PostMessageEnvelope, + PostMessageTarget, +} from '@metamask/streams/browser'; +import { isJsonRpcRequest, isJsonRpcResponse } from '@metamask/utils'; + +// Appears in the docs. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { PlatformServicesClient } from './PlatformServicesClient.ts'; + +export type VatWorker = { + launch: (vatConfig: VatConfig) => Promise<[MessagePort, unknown]>; + terminate: () => Promise; +}; + +export type PlatformServicesStream = PostMessageDuplexStream< + MessageEvent, // was JsonRpcRequest + PostMessageEnvelope // was JsonRpcResponse +>; + +export class PlatformServicesServer { + readonly #logger; + + readonly #stream: PlatformServicesStream; + + /** RPC client for delivering remote message events to the kernel */ + readonly #rpcClient: RpcClient; + + /** RPC service for handling platform services requests */ + readonly #rpcServer: RpcService; + + readonly #vatWorkers: Map = new Map(); + + readonly #makeWorker: (vatId: VatId) => VatWorker; + + #sendRemoteMessageFunc: SendRemoteMessage | null = null; + + /** + * **ATTN:** Prefer {@link PlatformServicesServer.make} over constructing + * this class directly. + * + * The server end of the platform services, intended to be constructed in + * the offscreen document. Listens for launch and terminate worker requests + * from the client and uses the {@link VatWorker} methods to effect those + * requests, and provides network connectivity. + * + * Note that {@link PlatformServicesServer.start} must be called to start + * the server. + * + * @see {@link PlatformServicesClient} for the other end of the service. + * + * @param stream - The stream to use for communication with the client. + * @param makeWorker - A method for making a {@link VatWorker}. + * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[platform services server]'. + */ + constructor( + stream: PlatformServicesStream, + makeWorker: (vatId: VatId) => VatWorker, + logger?: Logger, + ) { + this.#stream = stream; + this.#makeWorker = makeWorker; + this.#logger = logger ?? new Logger('platform-services-server'); + + this.#rpcClient = new RpcClient( + kernelRemoteMethodSpecs, + async (request) => { + await this.#sendMessage(request); + }, + `vws:`, + this.#logger.subLogger({ tags: ['rpc-client'] }), + ); + + this.#rpcServer = new RpcService(platformServicesHandlers, { + launch: this.#launch.bind(this), + terminate: this.#terminate.bind(this), + terminateAll: this.#terminateAll.bind(this), + initializeRemoteComms: this.#initializeRemoteComms.bind(this), + sendRemoteMessage: this.#sendRemoteMessage.bind(this), + }); + + // Start draining messages immediately after construction + this.#stream.drain(this.#handleMessage.bind(this)).catch((error) => { + this.#logger.error('Error draining stream:', error); + }); + } + + /** + * Create a new {@link PlatformServicesServer}. Does not start the server. + * + * @param messageTarget - The target to use for posting and receiving messages. + * @param makeWorker - A method for making a {@link VatWorker}. + * @param logger - An optional {@link Logger}. + * @returns A new {@link PlatformServicesServer}. + */ + static async make( + messageTarget: PostMessageTarget, + makeWorker: (vatId: VatId) => VatWorker, + logger?: Logger, + ): Promise { + const stream: PlatformServicesStream = new PostMessageDuplexStream({ + messageTarget, + messageEventMode: 'event', + validateInput: (message): message is MessageEvent => + message instanceof MessageEvent && isJsonRpcMessage(message.data), + }); + await stream.synchronize(); + return new PlatformServicesServer(stream, makeWorker, logger); + } + + async #handleMessage(event: MessageEvent): Promise { + if (isJsonRpcResponse(event.data)) { + const message = event.data; + this.#rpcClient.handleResponse(message.id as string, message); + } else if (isJsonRpcRequest(event.data)) { + const { id, method, params } = event.data; + try { + this.#rpcServer.assertHasMethod(method); + // Ridiculous cast to bypass TypeScript vs. JsonRpc tug-o-war + const port: MessagePort | undefined = (await this.#rpcServer.execute( + method, + params, + )) as unknown as MessagePort | undefined; + await this.#sendMessage({ id, result: null, jsonrpc: '2.0' }, port); + } catch (error) { + this.#logger.error(`Error handling "${method}" request:`, error); + this.#sendMessage({ + id, + error: serializeError(error), + jsonrpc: '2.0', + }).catch(() => undefined); + } + } + } + + async #sendMessage( + message: JsonRpcMessage, + port?: MessagePort, + ): Promise { + await this.#stream.write({ + payload: message, + transfer: port ? [port] : [], + }); + } + + async #launch(vatId: VatId, vatConfig: VatConfig): Promise { + if (this.#vatWorkers.has(vatId)) { + throw new VatAlreadyExistsError(vatId); + } + const vatWorker = this.#makeWorker(vatId); + const [port] = await vatWorker.launch(vatConfig); + this.#vatWorkers.set(vatId, vatWorker); + // This cast is a deliberate lie, to bypass TypeScript vs. JsonRpc tug-o-war + return port as unknown as null; + } + + async #terminate(vatId: VatId): Promise { + const vatWorker = this.#vatWorkers.get(vatId); + if (!vatWorker) { + throw new VatNotFoundError(vatId); + } + await vatWorker.terminate(); + this.#vatWorkers.delete(vatId); + return null; + } + + async #terminateAll(): Promise { + await Promise.all( + Array.from(this.#vatWorkers.keys()).map(async (vatId) => + this.#terminate(vatId), + ), + ); + return null; + } + + async #initializeRemoteComms( + keySeed: string, + knownRelays: string[], + ): Promise { + if (this.#sendRemoteMessageFunc) { + throw Error('remote comms already initialized'); + } + this.#sendRemoteMessageFunc = await initNetwork( + keySeed, + knownRelays, + this.#handleRemoteMessage.bind(this), + ); + return null; + } + + async #sendRemoteMessage(peerId: string, message: string): Promise { + if (!this.#sendRemoteMessageFunc) { + throw Error('remote comms not initialized'); + } + await this.#sendRemoteMessageFunc(peerId, message); + return null; + } + + async #handleRemoteMessage(from: string, message: string): Promise { + const possibleReply = await this.#rpcClient.call('remoteDeliver', { + from, + message, + }); + if (possibleReply !== '') { + await this.#sendRemoteMessage(from, possibleReply); + } + return ''; + } +} +harden(PlatformServicesServer); diff --git a/packages/kernel-browser-runtime/src/VatWorkerClient.ts b/packages/kernel-browser-runtime/src/VatWorkerClient.ts deleted file mode 100644 index a9df407a18..0000000000 --- a/packages/kernel-browser-runtime/src/VatWorkerClient.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { RpcClient } from '@metamask/kernel-rpc-methods'; -import type { JsonRpcCall, JsonRpcMessage } from '@metamask/kernel-utils'; -import { isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; -import { Logger } from '@metamask/logger'; -import type { VatWorkerService, VatId, VatConfig } from '@metamask/ocap-kernel'; -import { vatWorkerServiceMethodSpecs } from '@metamask/ocap-kernel/rpc'; -import type { DuplexStream } from '@metamask/streams'; -import { - MessagePortDuplexStream, - PostMessageDuplexStream, -} from '@metamask/streams/browser'; -import type { - PostMessageEnvelope, - PostMessageTarget, -} from '@metamask/streams/browser'; -import { isJsonRpcResponse } from '@metamask/utils'; -import type { JsonRpcId, JsonRpcResponse } from '@metamask/utils'; - -// Appears in the docs. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { VatWorkerServer } from './VatWorkerServer.ts'; - -export type VatWorkerClientStream = PostMessageDuplexStream< - MessageEvent, - PostMessageEnvelope ->; - -export class VatWorkerClient implements VatWorkerService { - readonly #logger: Logger; - - readonly #stream: VatWorkerClientStream; - - readonly #rpcClient: RpcClient; - - readonly #portMap: Map; - - /** - * **ATTN:** Prefer {@link VatWorkerClient.make} over constructing - * this class directly. - * - * The client end of the vat worker service, intended to be constructed in - * the kernel worker. Sends launch and terminate worker requests to the - * server and wraps the launch response in a DuplexStream for consumption - * by the kernel. - * - * @see {@link VatWorkerServer} for the other end of the service. - * - * @param stream - The stream to use for communication with the server. - * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker client]'. - */ - constructor(stream: VatWorkerClientStream, logger?: Logger) { - this.#stream = stream; - this.#portMap = new Map(); - this.#logger = logger ?? new Logger('vat-worker-client'); - this.#rpcClient = new RpcClient( - vatWorkerServiceMethodSpecs, - async (request) => { - if ('id' in request) { - if (request.method === 'launch') { - this.#portMap.set(request.id, undefined); - } - } - await this.#stream.write({ payload: request, transfer: [] }); - }, - 'm', - this.#logger, - ); - - // Start draining messages immediately after construction - // This runs for the lifetime of the client - this.#stream.drain(this.#handleMessage.bind(this)).catch((error) => { - this.#logger.error('Error draining stream:', error); - }); - } - - /** - * Create and initialize a new {@link VatWorkerClient}. - * The client will be ready to handle vat launches after this completes. - * - * @param messageTarget - The target to use for posting and receiving messages. - * @param logger - An optional {@link Logger}. - * @returns A promise for the initialized {@link VatWorkerClient}. - */ - static async make( - messageTarget: PostMessageTarget, - logger?: Logger, - ): Promise { - const stream: VatWorkerClientStream = new PostMessageDuplexStream({ - messageTarget, - messageEventMode: 'event', - validateInput: (message): message is MessageEvent => - message instanceof MessageEvent && isJsonRpcResponse(message.data), - }); - // Synchronize the stream before creating the client - await stream.synchronize(); - // Now create the client which will start draining immediately - return new VatWorkerClient(stream, logger); - } - - async launch( - vatId: VatId, - vatConfig: VatConfig, - ): Promise> { - const [id] = await this.#rpcClient.callAndGetId('launch', { - vatId, - vatConfig, - }); - const port = this.#portMap.get(id); - if (!port) { - throw new Error( - `No port found for launch of: ${stringify({ vatId, vatConfig })}`, - ); - } - this.#portMap.delete(id); - return await MessagePortDuplexStream.make( - port, - isJsonRpcMessage, - ); - } - - async terminate(vatId: VatId): Promise { - await this.#rpcClient.call('terminate', { vatId }); - } - - async terminateAll(): Promise { - await this.#rpcClient.call('terminateAll', []); - } - - async #handleMessage(event: MessageEvent): Promise { - const { id } = event.data; - const port = event.ports.at(0); - if (typeof id !== 'string') { - this.#logger.error( - 'Received response with unexpected id:', - stringify(event.data), - ); - return; - } - - if (this.#portMap.has(id)) { - this.#portMap.set(id, port); - } else if (port !== undefined) { - this.#logger.error( - 'Received message with unexpected port:', - stringify(event.data), - ); - } - - this.#rpcClient.handleResponse(id, event.data); - } -} -harden(VatWorkerClient); diff --git a/packages/kernel-browser-runtime/src/VatWorkerServer.ts b/packages/kernel-browser-runtime/src/VatWorkerServer.ts deleted file mode 100644 index 827851deec..0000000000 --- a/packages/kernel-browser-runtime/src/VatWorkerServer.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - VatAlreadyExistsError, - VatNotFoundError, -} from '@metamask/kernel-errors'; -import type { ExtractParams } from '@metamask/kernel-rpc-methods'; -import { Logger } from '@metamask/logger'; -import type { VatId, VatConfig } from '@metamask/ocap-kernel'; -import type { VatWorkerServiceMethod } from '@metamask/ocap-kernel/rpc'; -import { vatWorkerServiceMethodSpecs } from '@metamask/ocap-kernel/rpc'; -import { rpcErrors, serializeError } from '@metamask/rpc-errors'; -import { PostMessageDuplexStream } from '@metamask/streams/browser'; -import type { - PostMessageEnvelope, - PostMessageTarget, -} from '@metamask/streams/browser'; -import { hasProperty, isJsonRpcRequest } from '@metamask/utils'; -import type { - JsonRpcId, - JsonRpcParams, - JsonRpcRequest, - JsonRpcResponse, -} from '@metamask/utils'; - -// Appears in the docs. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { VatWorkerClient } from './VatWorkerClient.ts'; - -export type VatWorker = { - launch: (vatConfig: VatConfig) => Promise<[MessagePort, unknown]>; - terminate: () => Promise; -}; - -export type VatWorkerServiceStream = PostMessageDuplexStream< - MessageEvent, - PostMessageEnvelope ->; - -export class VatWorkerServer { - readonly #logger; - - readonly #stream: VatWorkerServiceStream; - - readonly #vatWorkers: Map = new Map(); - - readonly #makeWorker: (vatId: VatId) => VatWorker; - - /** - * **ATTN:** Prefer {@link VatWorkerServer.make} over constructing - * this class directly. - * - * The server end of the vat worker service, intended to be constructed in - * the offscreen document. Listens for launch and terminate worker requests - * from the client and uses the {@link VatWorker} methods to effect those - * requests. - * - * Note that {@link VatWorkerServer.start} must be called to start - * the server. - * - * @see {@link VatWorkerClient} for the other end of the service. - * - * @param stream - The stream to use for communication with the client. - * @param makeWorker - A method for making a {@link VatWorker}. - * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker server]'. - */ - constructor( - stream: VatWorkerServiceStream, - makeWorker: (vatId: VatId) => VatWorker, - logger?: Logger, - ) { - this.#stream = stream; - this.#makeWorker = makeWorker; - this.#logger = logger ?? new Logger('vat-worker-server'); - - // Start draining messages immediately after construction - this.#stream.drain(this.#handleMessage.bind(this)).catch((error) => { - this.#logger.error('Error draining stream:', error); - }); - } - - /** - * Create a new {@link VatWorkerServer}. - * - * @param messageTarget - The target to use for posting and receiving messages. - * @param makeWorker - A method for making a {@link VatWorker}. - * @param logger - An optional {@link Logger}. - * @returns A new {@link VatWorkerServer}. - */ - static async make( - messageTarget: PostMessageTarget, - makeWorker: (vatId: VatId) => VatWorker, - logger?: Logger, - ): Promise { - const stream: VatWorkerServiceStream = new PostMessageDuplexStream({ - messageTarget, - messageEventMode: 'event', - validateInput: (message): message is MessageEvent => - message instanceof MessageEvent && isJsonRpcRequest(message.data), - }); - await stream.synchronize(); - return new VatWorkerServer(stream, makeWorker, logger); - } - - #assertHasMethod(method: string): asserts method is VatWorkerServiceMethod { - if (!hasProperty(vatWorkerServiceMethodSpecs, method)) { - throw rpcErrors.methodNotFound(); - } - } - - #assertParams( - method: Method, - params: unknown, - ): asserts params is ExtractParams< - Method, - typeof vatWorkerServiceMethodSpecs - > { - vatWorkerServiceMethodSpecs[method].params.assert(params); - } - - async #handleMessage(event: MessageEvent): Promise { - const { id, method, params } = event.data; - try { - await this.#executeMethod(id, method, params); - } catch (error) { - this.#logger.error(`Error handling "${method}" request:`, error); - this.#sendMessage({ - id, - error: serializeError(error), - jsonrpc: '2.0', - }).catch(() => undefined); - } - } - - async #executeMethod( - messageId: JsonRpcId, - method: string, - params: JsonRpcParams | undefined, - ): Promise { - this.#assertHasMethod(method); - - let port: MessagePort | undefined; - - switch (method) { - case 'launch': { - this.#assertParams(method, params); - const { vatId, vatConfig } = params; - port = await this.#launch(vatId, vatConfig); - break; - } - case 'terminate': - this.#assertParams(method, params); - await this.#terminate(params.vatId); - break; - case 'terminateAll': - this.#assertParams(method, params); - await Promise.all( - Array.from(this.#vatWorkers.keys()).map(async (vatId) => - this.#terminate(vatId), - ), - ); - break; - default: - this.#logger.error( - 'Received message with unexpected method', - // @ts-expect-error Compile-time exhaustiveness check - method.valueOf(), - ); - throw rpcErrors.methodNotFound(); - } - await this.#sendMessage( - { id: messageId, result: null, jsonrpc: '2.0' }, - port, - ); - } - - async #sendMessage( - message: JsonRpcResponse, - port?: MessagePort, - ): Promise { - await this.#stream.write({ - payload: message, - transfer: port ? [port] : [], - }); - } - - async #launch(vatId: VatId, vatConfig: VatConfig): Promise { - if (this.#vatWorkers.has(vatId)) { - throw new VatAlreadyExistsError(vatId); - } - const vatWorker = this.#makeWorker(vatId); - const [port] = await vatWorker.launch(vatConfig); - this.#vatWorkers.set(vatId, vatWorker); - return port; - } - - async #terminate(vatId: VatId): Promise { - const vatWorker = this.#vatWorkers.get(vatId); - if (!vatWorker) { - throw new VatNotFoundError(vatId); - } - await vatWorker.terminate(); - this.#vatWorkers.delete(vatId); - } -} -harden(VatWorkerServer); diff --git a/packages/kernel-browser-runtime/src/default-cluster.json b/packages/kernel-browser-runtime/src/default-cluster.json index fdd917ff30..39acb7ded1 100644 --- a/packages/kernel-browser-runtime/src/default-cluster.json +++ b/packages/kernel-browser-runtime/src/default-cluster.json @@ -1,6 +1,7 @@ { "bootstrap": "alice", "forceReset": true, + "services": ["ocapURLIssuerService", "ocapURLRedemptionService"], "vats": { "alice": { "bundleSpec": "http://localhost:3000/sample-vat.bundle", diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index 60374047c2..3018ffaa44 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -5,8 +5,8 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ - 'VatWorkerClient', - 'VatWorkerServer', + 'PlatformServicesClient', + 'PlatformServicesServer', 'establishKernelConnection', 'makeIframeVatWorker', 'receiveUiConnections', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index ada33b7765..47912004c4 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -8,5 +8,5 @@ export type { KernelControlStream, } from './ui-connections.ts'; export * from './makeIframeVatWorker.ts'; -export * from './VatWorkerClient.ts'; -export * from './VatWorkerServer.ts'; +export * from './PlatformServicesClient.ts'; +export * from './PlatformServicesServer.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index 28536ef5e8..93be0a4a02 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -12,8 +12,8 @@ import { import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import defaultSubcluster from '../default-cluster.json'; +import { PlatformServicesClient } from '../PlatformServicesClient.ts'; import { receiveUiConnections } from '../ui-connections.ts'; -import { VatWorkerClient } from '../VatWorkerClient.ts'; import { makeLoggingMiddleware } from './middleware/logging.ts'; import { createPanelMessageMiddleware } from './middleware/panel-message.ts'; @@ -32,19 +32,20 @@ async function main(): Promise { ); // Initialize kernel dependencies - const [kernelStream, vatWorkerClient, kernelDatabase] = await Promise.all([ - MessagePortDuplexStream.make( - port, - isJsonRpcCall, - ), - VatWorkerClient.make(globalThis as PostMessageTarget), - makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), - ]); + const [kernelStream, platformServicesClient, kernelDatabase] = + await Promise.all([ + MessagePortDuplexStream.make( + port, + isJsonRpcCall, + ), + PlatformServicesClient.make(globalThis as PostMessageTarget), + makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), + ]); const firstTime = !kernelDatabase.kernelKVStore.get('initialized'); const kernel = await Kernel.make( kernelStream, - vatWorkerClient, + platformServicesClient, kernelDatabase, ); @@ -58,9 +59,14 @@ async function main(): Promise { logger, }); - // Launch the default subcluster if this is the first time - if (firstTime) { - const result = await kernel.launchSubcluster(defaultSubcluster); - logger.info(`Subcluster launched: ${JSON.stringify(result)}`); - } + await Promise.all([ + kernel.initRemoteComms(), + (async () => { + // Launch the default subcluster if this is the first time + if (firstTime) { + const result = await kernel.launchSubcluster(defaultSubcluster); + logger.info(`Subcluster launched: ${JSON.stringify(result)}`); + } + })(), + ]); } diff --git a/packages/kernel-browser-runtime/src/makeIframeVatWorker.ts b/packages/kernel-browser-runtime/src/makeIframeVatWorker.ts index 4ace3146e6..924e92b4a6 100644 --- a/packages/kernel-browser-runtime/src/makeIframeVatWorker.ts +++ b/packages/kernel-browser-runtime/src/makeIframeVatWorker.ts @@ -3,7 +3,7 @@ import type { VatId, VatConfig } from '@metamask/ocap-kernel'; import { createWindow } from '@metamask/snaps-utils'; import type { initializeMessageChannel } from '@metamask/streams/browser'; -import type { VatWorker } from './VatWorkerServer.ts'; +import type { VatWorker } from './PlatformServicesServer.ts'; type Options = { id: VatId; @@ -45,16 +45,16 @@ export const makeIframeVatWorker = ({ return [port, newWindow]; }, - terminate: async (): Promise => { + terminate: async (): Promise => { const iframe = document.getElementById(vatHtmlId); if (iframe === null) { logger.error( `iframe of vat with id "${id}" already removed from DOM (#${vatHtmlId})`, ); - return undefined; + return null; } iframe.remove(); - return undefined; + return null; }, }; }; diff --git a/packages/kernel-errors/vitest.config.ts b/packages/kernel-errors/vitest.config.ts index e571a6fd7d..7f220cb26e 100644 --- a/packages/kernel-errors/vitest.config.ts +++ b/packages/kernel-errors/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig((args) => { defaultConfig, defineProject({ test: { - name: 'errors', + name: 'kernel-errors', setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), }, }), diff --git a/packages/kernel-rpc-methods/vitest.config.ts b/packages/kernel-rpc-methods/vitest.config.ts index 859e7cd5e7..a5dce6e345 100644 --- a/packages/kernel-rpc-methods/vitest.config.ts +++ b/packages/kernel-rpc-methods/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig((args) => { defaultConfig, defineProject({ test: { - name: 'rpc-methods', + name: 'kernel-rpc-methods', setupFiles: ['./test/setup.ts'], }, }), diff --git a/packages/kernel-shims/package.json b/packages/kernel-shims/package.json index 75c90f9d1b..1e02c1f03b 100644 --- a/packages/kernel-shims/package.json +++ b/packages/kernel-shims/package.json @@ -20,6 +20,7 @@ "type": "module", "exports": { "./endoify": "./dist/endoify.js", + "./endoify-repair": "./dist/endoify-repair.js", "./eventual-send": "./dist/eventual-send.js", "./package.json": "./package.json" }, diff --git a/packages/kernel-shims/scripts/bundle.js b/packages/kernel-shims/scripts/bundle.js index 05badd5cf6..0040ce441c 100644 --- a/packages/kernel-shims/scripts/bundle.js +++ b/packages/kernel-shims/scripts/bundle.js @@ -11,7 +11,7 @@ import { rimraf } from 'rimraf'; console.log('Bundling shims...'); -const shims = ['endoify.js', 'eventual-send.js']; +const shims = ['endoify.js', 'endoify-repair.js', 'eventual-send.js']; const rootDir = fileURLToPath(new URL('..', import.meta.url)); const srcDir = path.resolve(rootDir, 'src'); diff --git a/packages/kernel-shims/src/endoify-repair.js b/packages/kernel-shims/src/endoify-repair.js new file mode 100644 index 0000000000..d68b6b96c4 --- /dev/null +++ b/packages/kernel-shims/src/endoify-repair.js @@ -0,0 +1,13 @@ +/* global repairIntrinsics */ +import 'ses'; +import '@endo/eventual-send/shim.js'; + +const isTest = import.meta?.env?.MODE === 'test'; + +repairIntrinsics({ + consoleTaming: 'unsafe', + errorTaming: isTest ? 'unsafe-debug' : 'unsafe', + overrideTaming: 'severe', + domainTaming: 'unsafe', + stackFiltering: isTest ? 'verbose' : 'concise', +}); diff --git a/packages/kernel-shims/vitest.config.ts b/packages/kernel-shims/vitest.config.ts index d96a78e6ec..712a5c32cf 100644 --- a/packages/kernel-shims/vitest.config.ts +++ b/packages/kernel-shims/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig((args) => { defaultConfig, defineProject({ test: { - name: 'shims', + name: 'kernel-shims', }, }), ); diff --git a/packages/kernel-store/vitest.config.ts b/packages/kernel-store/vitest.config.ts index a4a97ab830..1672f9c9d6 100644 --- a/packages/kernel-store/vitest.config.ts +++ b/packages/kernel-store/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig((args) => { defaultConfig, defineProject({ test: { - name: 'store', + name: 'kernel-store', setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), }, }), diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index 9478e5da50..ea30e73882 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -50,7 +50,6 @@ "@endo/marshal": "^1.8.0", "@endo/patterns": "^1.7.0", "@endo/promise-kit": "^1.1.13", - "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", diff --git a/packages/kernel-test/src/async-generator.test.ts b/packages/kernel-test/src/async-generator.test.ts index 1698d8a475..36367dc968 100644 --- a/packages/kernel-test/src/async-generator.test.ts +++ b/packages/kernel-test/src/async-generator.test.ts @@ -33,7 +33,7 @@ const testSubcluster = { describe( 'Async generator consumption between vats', { - timeout: 2000, + timeout: 5000, }, () => { let kernel: Kernel; diff --git a/packages/kernel-test/src/exo.test.ts b/packages/kernel-test/src/exo.test.ts index d10c7b325e..bc84003501 100644 --- a/packages/kernel-test/src/exo.test.ts +++ b/packages/kernel-test/src/exo.test.ts @@ -26,6 +26,9 @@ const testSubcluster = { }, }; +// First vat root object is ko3 due to kernel service objects being created before any vats. +const exoTestRoot: KRef = 'ko3'; + describe('virtual objects functionality', async () => { let kernel: Kernel; let logEntries: LogEntry[]; @@ -75,7 +78,11 @@ describe('virtual objects functionality', async () => { it('tests scalar store functionality', async () => { expect(bootstrapResult).toBe('exo-test-complete'); clearLogEntries(); - const storeResult = await kernel.queueMessage('ko1', 'testScalarStore', []); + const storeResult = await kernel.queueMessage( + exoTestRoot, + 'testScalarStore', + [], + ); await waitUntilQuiescent(100); expect(kunser(storeResult)).toBe('scalar-store-tests-complete'); const vatLogs = extractTestLogs(logEntries, 'ExoTest'); @@ -91,9 +98,11 @@ describe('virtual objects functionality', async () => { it('can create and use objects through messaging', async () => { expect(bootstrapResult).toBe('exo-test-complete'); clearLogEntries(); - const counterResult = await kernel.queueMessage('ko1', 'createCounter', [ - 42, - ]); + const counterResult = await kernel.queueMessage( + exoTestRoot, + 'createCounter', + [42], + ); await waitUntilQuiescent(); const counterRef = counterResult.slots[0] as KRef; const incrementResult = await kernel.queueMessage(counterRef, 'increment', [ @@ -102,26 +111,29 @@ describe('virtual objects functionality', async () => { // Verify the increment result expect(kunser(incrementResult)).toBe(47); await waitUntilQuiescent(); - const personResult = await kernel.queueMessage('ko1', 'createPerson', [ - 'Dave', - 35, - ]); + const personResult = await kernel.queueMessage( + exoTestRoot, + 'createPerson', + ['Dave', 35], + ); await waitUntilQuiescent(); const personRef = personResult.slots[0] as KRef; - await kernel.queueMessage('ko1', 'createOrUpdateInMap', [ + await kernel.queueMessage(exoTestRoot, 'createOrUpdateInMap', [ 'dave', personRef, ]); await waitUntilQuiescent(); // Get object from map store - const retrievedPerson = await kernel.queueMessage('ko1', 'getFromMap', [ - 'dave', - ]); + const retrievedPerson = await kernel.queueMessage( + exoTestRoot, + 'getFromMap', + ['dave'], + ); await waitUntilQuiescent(); // Verify the retrieved person object expect(kunser(retrievedPerson)).toBe(personRef); - await kernel.queueMessage('ko1', 'createOrUpdateInMap', [ + await kernel.queueMessage(exoTestRoot, 'createOrUpdateInMap', [ 'dave', personRef, ]); @@ -140,7 +152,11 @@ describe('virtual objects functionality', async () => { it('tests exoClass type validation and behavior', async () => { expect(bootstrapResult).toBe('exo-test-complete'); clearLogEntries(); - const exoClassResult = await kernel.queueMessage('ko1', 'testExoClass', []); + const exoClassResult = await kernel.queueMessage( + exoTestRoot, + 'testExoClass', + [], + ); await waitUntilQuiescent(100); expect(kunser(exoClassResult)).toBe('exoClass-tests-complete'); const vatLogs = extractTestLogs(logEntries, 'ExoTest'); @@ -155,7 +171,7 @@ describe('virtual objects functionality', async () => { expect(bootstrapResult).toBe('exo-test-complete'); clearLogEntries(); const exoClassKitResult = await kernel.queueMessage( - 'ko1', + exoTestRoot, 'testExoClassKit', [], ); @@ -173,9 +189,11 @@ describe('virtual objects functionality', async () => { expect(bootstrapResult).toBe('exo-test-complete'); clearLogEntries(); // Create a temperature converter starting at 100°C - const tempResult = await kernel.queueMessage('ko1', 'createTemperature', [ - 100, - ]); + const tempResult = await kernel.queueMessage( + exoTestRoot, + 'createTemperature', + [100], + ); await waitUntilQuiescent(); // Get both facets from the result const tempKit = tempResult; diff --git a/packages/kernel-test/src/logger.test.ts b/packages/kernel-test/src/logger.test.ts index a296bdbdec..e359c0fa3a 100644 --- a/packages/kernel-test/src/logger.test.ts +++ b/packages/kernel-test/src/logger.test.ts @@ -31,7 +31,7 @@ describe('logger', () => { expect(vats).toStrictEqual([vatId]); await waitUntilQuiescent(); - await kernel.queueMessage('ko1', 'foo', []); + await kernel.queueMessage('ko3', 'foo', []); await waitUntilQuiescent(); const vatLogs = extractTestLogs(entries, vatId); diff --git a/packages/kernel-test/src/persistence.test.ts b/packages/kernel-test/src/persistence.test.ts index 0ed426a6b9..129b441c63 100644 --- a/packages/kernel-test/src/persistence.test.ts +++ b/packages/kernel-test/src/persistence.test.ts @@ -13,6 +13,8 @@ import { runTestVats, } from './utils.ts'; +const v1Root = 'ko3'; + describe('persistent storage', { timeout: 10_000 }, () => { let logger: ReturnType; let databasePath: string; @@ -54,7 +56,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { const result1 = await runTestVats(kernel1, testSubcluster); expect(result1).toBe('Counter initialized with count: 1'); await waitUntilQuiescent(); - const incrementResult1 = await runResume(kernel1, 'ko1'); + const incrementResult1 = await runResume(kernel1, v1Root); expect(incrementResult1).toBe('Counter incremented to: 2'); await waitUntilQuiescent(); await kernel1.stop(); @@ -64,7 +66,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { logger.logger.subLogger({ tags: ['test'] }), ); await new Promise((resolve) => setTimeout(resolve, 1000)); - const resumeResult = await runResume(kernel2, 'ko1'); + const resumeResult = await runResume(kernel2, v1Root); expect(resumeResult).toBe('Counter incremented to: 3'); }); @@ -95,7 +97,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { const result1 = await runTestVats(kernel1, multiVatCluster); expect(result1).toBe('Coordinator initialized with 2 workers'); await waitUntilQuiescent(); - const workResult1 = await runResume(kernel1, 'ko1'); + const workResult1 = await runResume(kernel1, v1Root); expect(workResult1).toBe('Work completed: Worker1(1), Worker2(1)'); await waitUntilQuiescent(); await kernel1.stop(); @@ -105,7 +107,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { logger.logger.subLogger({ tags: ['test'] }), ); await new Promise((resolve) => setTimeout(resolve, 1000)); - const workResult2 = await runResume(kernel2, 'ko1'); + const workResult2 = await runResume(kernel2, v1Root); expect(workResult2).toBe('Work completed: Worker1(2), Worker2(2)'); }); @@ -118,7 +120,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { ); await runTestVats(kernel1, testSubcluster); await waitUntilQuiescent(); - await runResume(kernel1, 'ko1'); + await runResume(kernel1, v1Root); await waitUntilQuiescent(); await kernel1.stop(); const database2 = await makeSQLKernelDatabase({ dbFilename: databasePath }); @@ -139,9 +141,9 @@ describe('persistent storage', { timeout: 10_000 }, () => { logger.logger.subLogger({ tags: ['test'] }), ); await runTestVats(kernel, testSubcluster); - await runResume(kernel, 'ko1'); + await runResume(kernel, v1Root); await kernel.restartVat('v1'); - const resumeResult = await runResume(kernel, 'ko1'); + const resumeResult = await runResume(kernel, v1Root); expect(resumeResult).toBe('Counter incremented to: 3'); }); @@ -159,12 +161,12 @@ describe('persistent storage', { timeout: 10_000 }, () => { ); await waitUntilQuiescent(); // Process one message to verify the vat is working - const result1 = await kernel1.queueMessage('ko1', 'resume', []); + const result1 = await kernel1.queueMessage(v1Root, 'resume', []); expect(kunser(result1)).toBe('Counter incremented to: 2'); // Enqueue a send message into the database kernelStore.kv.set('queue.run.head', '4'); kernelStore.kv.set('nextPromiseId', '4'); - kernelStore.kv.set('ko1.refCount', '3,3'); + kernelStore.kv.set(`${v1Root}.refCount`, '3,3'); kernelStore.kv.set('queue.kp3.head', '1'); kernelStore.kv.set('queue.kp3.tail', '1'); kernelStore.kv.set('kp3.state', 'unresolved'); @@ -172,7 +174,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { kernelStore.kv.set('kp3.refCount', '2'); kernelStore.kv.set( 'queue.run.3', - '{"type":"send","target":"ko1","message":{"methargs":{"body":"#[\\"resume\\",[]]","slots":[]},"result":"kp3"}}', + `{"type":"send","target":"${v1Root}","message":{"methargs":{"body":"#[\\"resume\\",[]]","slots":[]},"result":"kp3"}}`, ); await kernel1.stop(); // verify that the message is in the database @@ -186,7 +188,7 @@ describe('persistent storage', { timeout: 10_000 }, () => { // verify that the run queue is empty expect(kernelStore.kv.get('queue.run.3')).toBeUndefined(); // verify that the message is processed and the counter is incremented - const result2 = await kernel2.queueMessage('ko1', 'resume', []); + const result2 = await kernel2.queueMessage(v1Root, 'resume', []); expect(kunser(result2)).toBe('Counter incremented to: 4'); }); }); diff --git a/packages/kernel-test/src/resume.test.ts b/packages/kernel-test/src/resume.test.ts index 8f8cd28e47..c4901f730e 100644 --- a/packages/kernel-test/src/resume.test.ts +++ b/packages/kernel-test/src/resume.test.ts @@ -1,5 +1,6 @@ import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import type { KRef } from '@metamask/ocap-kernel'; import { describe, expect, it } from 'vitest'; import { @@ -99,6 +100,11 @@ const reference = sortLogs([ ...carolResumeReference, ]); +// Vat root objects start with ko3 due to kernel service objects being created before any vats. +const v1Root: KRef = 'ko3'; +const v2Root: KRef = 'ko4'; +const v3Root: KRef = 'ko5'; + describe('restarting vats', async () => { it('exercise restart vats individually', async () => { const kernelDatabase = await makeSQLKernelDatabase({ @@ -112,11 +118,11 @@ describe('restarting vats', async () => { await kernel.restartVat('v1'); await kernel.restartVat('v2'); await kernel.restartVat('v3'); - const resumeResultA = await runResume(kernel, 'ko1'); + const resumeResultA = await runResume(kernel, v1Root); expect(resumeResultA).toBe('resume Alice'); - const resumeResultB = await runResume(kernel, 'ko2'); + const resumeResultB = await runResume(kernel, v2Root); expect(resumeResultB).toBe('resume Bob'); - const resumeResultC = await runResume(kernel, 'ko3'); + const resumeResultC = await runResume(kernel, v3Root); expect(resumeResultC).toBe('resume Carol'); await waitUntilQuiescent(1000); @@ -136,11 +142,11 @@ describe('restarting vats', async () => { const { logger: logger2, entries: entries2 } = makeTestLogger(); const kernel2 = await makeKernel(kernelDatabase, false, logger2); await new Promise((resolve) => setTimeout(resolve, 1000)); - const resumeResultA = await runResume(kernel2, 'ko1'); + const resumeResultA = await runResume(kernel2, v1Root); expect(resumeResultA).toBe('resume Alice'); - const resumeResultB = await runResume(kernel2, 'ko2'); + const resumeResultB = await runResume(kernel2, v2Root); expect(resumeResultB).toBe('resume Bob'); - const resumeResultC = await runResume(kernel2, 'ko3'); + const resumeResultC = await runResume(kernel2, v3Root); expect(resumeResultC).toBe('resume Carol'); await waitUntilQuiescent(1000); const vatLogs = extractTestLogs( diff --git a/packages/kernel-test/src/service.test.ts b/packages/kernel-test/src/service.test.ts index 08a3bd5e59..11f16f93c7 100644 --- a/packages/kernel-test/src/service.test.ts +++ b/packages/kernel-test/src/service.test.ts @@ -1,7 +1,7 @@ import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { makeDefaultExo, waitUntilQuiescent } from '@metamask/kernel-utils'; import { Kernel, krefOf } from '@metamask/ocap-kernel'; -import type { SlotValue } from '@metamask/ocap-kernel'; +import type { KRef, SlotValue } from '@metamask/ocap-kernel'; import { describe, expect, it } from 'vitest'; import { @@ -45,14 +45,15 @@ describe('Kernel service object invocation', () => { await runTestVats(kernel, testSubcluster); - // ko1 ::= the (test) service object - // ko2 ::= test vat root object - // ko3 ::= internal object generated inside test vat to have its kref extracted + // ko3 ::= the (test) service object + // ko4 ::= test vat root object + // ko5 ::= internal object generated inside test vat to have its kref extracted - await kernel.queueMessage('ko2', 'go', []); + const testVatRootObject: KRef = 'ko4'; + await kernel.queueMessage(testVatRootObject, 'go', []); await waitUntilQuiescent(100); const testLogs = extractTestLogs(entries); - expect(testLogs).toContain('kernel service returns hello -- ko3'); + expect(testLogs).toContain('kernel service returns hello -- ko5'); }); it('configure subcluster with unknown service throws', async () => { @@ -77,11 +78,12 @@ describe('Kernel service object invocation', () => { await runTestVats(kernel, testSubcluster); - // ko1 ::= the (test) service object - // ko2 ::= test vat root object - // ko3 ::= internal object generated inside test vat to have its kref extracted + // ko3 ::= the (test) service object + // ko4 ::= test vat root object + // ko5 ::= internal object generated inside test vat to have its kref extracted + const testVatRootObject: KRef = 'ko4'; - await kernel.queueMessage('ko2', 'goBadly', []); + await kernel.queueMessage(testVatRootObject, 'goBadly', []); await waitUntilQuiescent(100); const testLogs = extractTestLogs(entries); expect(testLogs).toContain( diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 4771c3db20..d4d507fb27 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -9,7 +9,7 @@ import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { NodejsVatWorkerService } from '@ocap/nodejs'; +import { NodejsPlatformServices } from '@ocap/nodejs'; import { MessagePort as NodeMessagePort, MessageChannel as NodeMessageChannel, @@ -82,12 +82,12 @@ export async function makeKernel( JsonRpcRequest, JsonRpcResponse >(kernelPort); - const vatWorkerClient = new NodejsVatWorkerService({ + const platformServicesClient = new NodejsPlatformServices({ logger: logger.subLogger({ tags: ['vat-worker-manager'] }), }); const kernel = await Kernel.make( nodeStream, - vatWorkerClient, + platformServicesClient, kernelDatabase, { resetStorage, diff --git a/packages/kernel-test/src/vat-lifecycle.test.ts b/packages/kernel-test/src/vat-lifecycle.test.ts index 851269c45e..130b4621be 100644 --- a/packages/kernel-test/src/vat-lifecycle.test.ts +++ b/packages/kernel-test/src/vat-lifecycle.test.ts @@ -137,7 +137,7 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => { 'resume', [], ); - expect(kunser(messageResult)).toBe('no vat'); + expect(kunser(messageResult)).toBe('no endpoint'); // Verify that messaging works as expected expect(await runResume(kernel, liveRootObject)).toBe( diff --git a/packages/kernel-test/src/vatstore.test.ts b/packages/kernel-test/src/vatstore.test.ts index 1d2661e5cb..7f6f72bba0 100644 --- a/packages/kernel-test/src/vatstore.test.ts +++ b/packages/kernel-test/src/vatstore.test.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '../../nodejs/src/env/endoify.ts'; import type { VatStore, VatCheckpoint } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import type { ClusterConfig } from '@metamask/ocap-kernel'; diff --git a/packages/kernel-test/vitest.config.ts b/packages/kernel-test/vitest.config.ts index 5de95606db..57eac41672 100644 --- a/packages/kernel-test/vitest.config.ts +++ b/packages/kernel-test/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel-test', - setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), + setupFiles: path.resolve(__dirname, '../nodejs/src/env/endoify.ts'), testTimeout: 30_000, }, }), diff --git a/packages/kernel-ui/src/hooks/useKernelActions.test.ts b/packages/kernel-ui/src/hooks/useKernelActions.test.ts index 8b573a207f..f1dd79a28d 100644 --- a/packages/kernel-ui/src/hooks/useKernelActions.test.ts +++ b/packages/kernel-ui/src/hooks/useKernelActions.test.ts @@ -98,7 +98,7 @@ describe('useKernelActions', () => { result.current.collectGarbage(); await waitFor(() => { expect(mockLogMessage).toHaveBeenCalledWith( - 'Failed to collect garbage', + 'Failed to collect garbage Error', 'error', ); }); diff --git a/packages/kernel-ui/src/hooks/useKernelActions.ts b/packages/kernel-ui/src/hooks/useKernelActions.ts index aea7cc41c0..51480d1a5d 100644 --- a/packages/kernel-ui/src/hooks/useKernelActions.ts +++ b/packages/kernel-ui/src/hooks/useKernelActions.ts @@ -38,7 +38,9 @@ export function useKernelActions(): { params: [], }) .then(() => logMessage('Garbage collected', 'success')) - .catch(() => logMessage('Failed to collect garbage', 'error')); + .catch((problem) => + logMessage(`Failed to collect garbage ${problem}`, 'error'), + ); }, [callKernelMethod, logMessage]); /** diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index f408857243..719fb031e1 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@endo/captp": "^4.4.8", + "@endo/errors": "^1.2.13", "@endo/exo": "^1.5.12", "@endo/patterns": "^1.7.0", "@endo/promise-kit": "^1.1.13", diff --git a/packages/kernel-utils/src/hex.ts b/packages/kernel-utils/src/hex.ts new file mode 100644 index 0000000000..db8d7ea528 --- /dev/null +++ b/packages/kernel-utils/src/hex.ts @@ -0,0 +1,48 @@ +import { assert } from '@endo/errors'; + +// XXX TODO: The following two hex conversion functions are placeholders. In +// particular, they're not as paranoid as they ought to be, which I'll +// rationalize by observing that we only use them on data of strictly internal +// provenance. However, I'm quite prepared to bet we can find any number of better, +// off-the-shelf packages in NPM that we could just use, and one of them +// probably should be substituted. + +/** + * Convert a Uint8Array into a hex string. + * + * @param arr - The bytes to convert. + * + * @returns `arr` represented as a hex string. + */ +export function toHex(arr: Uint8Array): string { + let result = ''; + for (const byte of arr) { + const byteHex = byte.toString(16); + result += byteHex.length === 1 ? `0${byteHex}` : byteHex; + } + return result; +} + +/** + * Convert a hex string into a 32-byte fixed-length Uint8Array. + * + * @param str - The hex string to convert. If shorter than the 64 characters + * needed to encode 32 bytes, remaining bytes in the result will be 0. + * + * @returns the bytes described by `str`. + */ +export function fromHex(str: string): Uint8Array { + const len = str.length; + assert(len <= 64); + const resultLen = len / 2; + const bytes = new Uint8Array(32); + let inIdx = 0; + let outIdx = 0; + while (outIdx < resultLen) { + const digits = str.slice(inIdx, inIdx + 2); + bytes[outIdx] = parseInt(digits, 16); + outIdx += 1; + inIdx += 2; + } + return bytes; +} diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 15ce54d32a..d82284de3b 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -8,6 +8,7 @@ describe('index', () => { 'EmptyJsonArray', 'delay', 'fetchValidatedJson', + 'fromHex', 'isJsonRpcCall', 'isJsonRpcMessage', 'isPrimitive', @@ -17,6 +18,7 @@ describe('index', () => { 'makeDefaultExo', 'makeDefaultInterface', 'stringify', + 'toHex', 'waitUntilQuiescent', ]); }); diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 3aaa9c9fb5..1ee034e3fb 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -18,3 +18,4 @@ export { isJsonRpcMessage, } from './types.ts'; export { waitUntilQuiescent } from './wait-quiescent.ts'; +export { fromHex, toHex } from './hex.ts'; diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 87b8324de3..388721fbd7 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@endo/promise-kit": "^1.1.13", + "@libp2p/webrtc": "^5.2.12", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", diff --git a/packages/nodejs/src/env/endoify.ts b/packages/nodejs/src/env/endoify.ts index 9075359621..d7123701ad 100644 --- a/packages/nodejs/src/env/endoify.ts +++ b/packages/nodejs/src/env/endoify.ts @@ -1 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '@metamask/kernel-shims/endoify-repair'; +import '@libp2p/webrtc'; + +hardenIntrinsics(); diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index cee5bafc42..6af1ec51b2 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -1,3 +1,3 @@ -export { NodejsVatWorkerService } from './kernel/VatWorkerService.ts'; +export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; diff --git a/packages/nodejs/src/kernel/VatWorkerService.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts similarity index 83% rename from packages/nodejs/src/kernel/VatWorkerService.test.ts rename to packages/nodejs/src/kernel/PlatformServices.test.ts index 607db1a5f6..2f3d3997b6 100644 --- a/packages/nodejs/src/kernel/VatWorkerService.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -1,10 +1,10 @@ -import '@metamask/kernel-shims/endoify'; +import '../env/endoify.ts'; import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { describe, expect, it, vi } from 'vitest'; -import { NodejsVatWorkerService } from './VatWorkerService.ts'; +import { NodejsPlatformServices } from './PlatformServices.ts'; const mocks = vi.hoisted(() => ({ worker: { @@ -27,10 +27,10 @@ vi.mock('node:worker_threads', () => ({ Worker: vi.fn(() => mocks.worker), })); -describe('NodejsVatWorkerService', () => { +describe('NodejsPlatformServices', () => { it('constructs an instance without any arguments', () => { - const instance = new NodejsVatWorkerService({}); - expect(instance).toBeInstanceOf(NodejsVatWorkerService); + const instance = new NodejsPlatformServices({}); + expect(instance).toBeInstanceOf(NodejsPlatformServices); }); const workerFilePath = 'unused'; @@ -39,7 +39,7 @@ describe('NodejsVatWorkerService', () => { describe('launch', () => { it('creates a NodeWorker and returns a NodeWorkerDuplexStream', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath, }); const testVatId: VatId = getTestVatId(); @@ -51,7 +51,7 @@ describe('NodejsVatWorkerService', () => { it('rejects if synchronize fails', async () => { const rejected = 'test-reject-value'; mocks.stream.synchronize.mockRejectedValue(rejected); - const service = new NodejsVatWorkerService({ workerFilePath }); + const service = new NodejsPlatformServices({ workerFilePath }); const testVatId: VatId = getTestVatId(); await expect(async () => await service.launch(testVatId)).rejects.toThrow( rejected, @@ -61,7 +61,7 @@ describe('NodejsVatWorkerService', () => { describe('terminate', () => { it('terminates the target vat', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath, }); const testVatId: VatId = getTestVatId(); @@ -74,7 +74,7 @@ describe('NodejsVatWorkerService', () => { }); it('throws when terminating an unknown vat', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath, }); const testVatId: VatId = getTestVatId(); @@ -87,7 +87,7 @@ describe('NodejsVatWorkerService', () => { describe('terminateAll', () => { it('terminates all vats', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath, }); const vatIds: VatId[] = [getTestVatId(), getTestVatId(), getTestVatId()]; diff --git a/packages/nodejs/src/kernel/VatWorkerService.ts b/packages/nodejs/src/kernel/PlatformServices.ts similarity index 64% rename from packages/nodejs/src/kernel/VatWorkerService.ts rename to packages/nodejs/src/kernel/PlatformServices.ts index 12ce97b9d5..e73dc6f2d5 100644 --- a/packages/nodejs/src/kernel/VatWorkerService.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -2,7 +2,13 @@ import { makePromiseKit } from '@endo/promise-kit'; import { isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import type { VatWorkerService, VatId } from '@metamask/ocap-kernel'; +import type { + PlatformServices, + VatId, + RemoteMessageHandler, + SendRemoteMessage, +} from '@metamask/ocap-kernel'; +import { initNetwork } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; import type { DuplexStream } from '@metamask/streams'; import { Worker as NodeWorker } from 'node:worker_threads'; @@ -14,9 +20,13 @@ const DEFAULT_WORKER_FILE = new URL( import.meta.url, ).pathname; -export class NodejsVatWorkerService implements VatWorkerService { +export class NodejsPlatformServices implements PlatformServices { readonly #logger: Logger; + #sendRemoteMessageFunc: SendRemoteMessage | null = null; + + #remoteMessageHandler: RemoteMessageHandler | undefined = undefined; + readonly #workerFilePath: string; workers = new Map< @@ -86,5 +96,40 @@ export class NodejsVatWorkerService implements VatWorkerService { await this.terminate(vatId); } } + + async sendRemoteMessage(from: string, message: string): Promise { + if (!this.#sendRemoteMessageFunc) { + throw Error('remote comms not initialized'); + } + await this.#sendRemoteMessageFunc(from, message); + } + + async #handleRemoteMessage(from: string, message: string): Promise { + if (!this.#remoteMessageHandler) { + // This can't actually happen, but TypeScript can't infer it + throw Error('remote comms not initialized'); + } + const possibleReply = await this.#remoteMessageHandler(from, message); + if (possibleReply !== '') { + await this.sendRemoteMessage(from, possibleReply); + } + return ''; + } + + async initializeRemoteComms( + keySeed: string, + knownRelays: string[], + remoteMessageHandler: (from: string, message: string) => Promise, + ): Promise { + if (this.#sendRemoteMessageFunc) { + throw Error('remote comms already initialized'); + } + this.#remoteMessageHandler = remoteMessageHandler; + this.#sendRemoteMessageFunc = await initNetwork( + keySeed, + knownRelays, + this.#handleRemoteMessage.bind(this), + ); + } } -harden(NodejsVatWorkerService); +harden(NodejsPlatformServices); diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index 972e6c8350..35b2f6689c 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '../env/endoify.ts'; import { Kernel } from '@metamask/ocap-kernel'; import { diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 77d4651d66..a68c88062b 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -5,7 +5,7 @@ import { NodeWorkerDuplexStream } from '@metamask/streams'; import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { MessagePort as NodeMessagePort } from 'node:worker_threads'; -import { NodejsVatWorkerService } from './VatWorkerService.ts'; +import { NodejsPlatformServices } from './PlatformServices.ts'; /** * The main function for the kernel worker. @@ -36,9 +36,9 @@ export async function makeKernel({ JsonRpcResponse >(port); const rootLogger = logger ?? new Logger('kernel-worker'); - const vatWorkerClient = new NodejsVatWorkerService({ + const platformServicesClient = new NodejsPlatformServices({ workerFilePath, - logger: rootLogger.subLogger({ tags: ['vat-worker-manager'] }), + logger: rootLogger.subLogger({ tags: ['platform-services-manager'] }), }); // Initialize kernel store. @@ -47,7 +47,7 @@ export async function makeKernel({ // Create and start kernel. const kernel = await Kernel.make( nodeStream, - vatWorkerClient, + platformServicesClient, kernelDatabase, { resetStorage, diff --git a/packages/nodejs/src/vat/streams.test.ts b/packages/nodejs/src/vat/streams.test.ts index 689a0150f0..2e51e8e37b 100644 --- a/packages/nodejs/src/vat/streams.test.ts +++ b/packages/nodejs/src/vat/streams.test.ts @@ -30,15 +30,17 @@ describe('vat/streams', () => { describe('getPort', () => { it('returns a port', async () => { doMockParentPort(mocks.parentPort); + vi.resetModules(); const { getPort } = await import('./streams.ts'); const port = getPort(); expect(port).toStrictEqual(mocks.parentPort); - }, 4000); // Extra time is needed when running yarn test from monorepo root. + }); it('throws if parentPort is not defined', async () => { doMockParentPort(undefined); + vi.resetModules(); const { getPort } = await import('./streams.ts'); @@ -49,6 +51,7 @@ describe('vat/streams', () => { describe('makeStreams', () => { it('returns two NodeWorkerDuplexStreams', async () => { doMockParentPort(mocks.parentPort); + vi.resetModules(); const { makeStreams } = await import('./streams.ts'); const { kernelStream, loggerStream } = await makeStreams(); diff --git a/packages/nodejs/src/vat/vat-worker.test.ts b/packages/nodejs/src/vat/vat-worker.test.ts index 28b9d0cd15..0bb5b22ee0 100644 --- a/packages/nodejs/src/vat/vat-worker.test.ts +++ b/packages/nodejs/src/vat/vat-worker.test.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '../env/endoify.ts'; import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; @@ -10,7 +10,7 @@ import { getTestWorkerFile } from '../../test/get-test-worker.ts'; const { makePromiseKit } = makePromiseKitMock(); -describe('NodejsVatWorkerService', () => { +describe('NodejsPlatformServices', () => { let testWorkerFile: string; const vatIdCounter = makeCounter(); const getTestVatId = (): VatId => `v${vatIdCounter()}`; diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 8d2f79f5b2..4eccdb1960 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '../env/endoify.ts'; import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/e2e/VatWorkerService.test.ts b/packages/nodejs/test/e2e/PlatformServices.test.ts similarity index 84% rename from packages/nodejs/test/e2e/VatWorkerService.test.ts rename to packages/nodejs/test/e2e/PlatformServices.test.ts index 2817c89e23..60f83d7188 100644 --- a/packages/nodejs/test/e2e/VatWorkerService.test.ts +++ b/packages/nodejs/test/e2e/PlatformServices.test.ts @@ -1,21 +1,21 @@ -import '@metamask/kernel-shims/endoify'; +import '../../src/env/endoify.ts'; import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; import { describe, expect, it, vi } from 'vitest'; -import { NodejsVatWorkerService } from '../../src/kernel/VatWorkerService.ts'; +import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; import { getTestWorkerFile } from '../get-test-worker.ts'; -describe('NodejsVatWorkerService', () => { +describe('NodejsPlatformServices', () => { const testWorkerFile = getTestWorkerFile('stream-sync'); const vatIdCounter = makeCounter(); const getTestVatId = (): VatId => `v${vatIdCounter()}`; describe('launch', () => { it('creates a NodeWorker and returns a NodeWorkerDuplexStream', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath: testWorkerFile, }); const testVatId: VatId = getTestVatId(); @@ -33,8 +33,8 @@ describe('NodejsVatWorkerService', () => { })), })); vi.resetModules(); - const NVWS = (await import('../../src/kernel/VatWorkerService.ts')) - .NodejsVatWorkerService; + const NVWS = (await import('../../src/kernel/PlatformServices.ts')) + .NodejsPlatformServices; const service = new NVWS({ workerFilePath: testWorkerFile }); const testVatId: VatId = getTestVatId(); @@ -46,7 +46,7 @@ describe('NodejsVatWorkerService', () => { describe('terminate', () => { it('terminates the target vat', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath: testWorkerFile, }); const testVatId: VatId = getTestVatId(); @@ -59,7 +59,7 @@ describe('NodejsVatWorkerService', () => { }); it('throws when terminating an unknown vat', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath: testWorkerFile, }); const testVatId: VatId = getTestVatId(); @@ -72,7 +72,7 @@ describe('NodejsVatWorkerService', () => { describe('terminateAll', () => { it('terminates all vats', async () => { - const service = new NodejsVatWorkerService({ + const service = new NodejsPlatformServices({ workerFilePath: testWorkerFile, }); const vatIds: VatId[] = [getTestVatId(), getTestVatId(), getTestVatId()]; diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 6e6ec0aaca..b04cdc3029 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/endoify'; +import '../../src/env/endoify.ts'; import { Kernel } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; @@ -86,11 +86,11 @@ describe('Kernel Worker', () => { await launchTestVats(); await kernel.terminateAllVats(); expect(kernel.getVatIds()).toHaveLength(0); - }); + }, 5000); it('pings vats', async () => { await launchTestVats(); const result = await kernel.pingVat('v1'); expect(result).toBe('pong'); - }); + }, 5000); }); diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index f17081fcdc..4135b79dfc 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -10,7 +10,6 @@ export default defineConfig((args) => { defineProject({ test: { name: 'nodejs', - pool: 'forks', include: ['./src/**/*.test.ts'], exclude: ['./test/e2e/'], }, diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index fa9e3a9b06..2be9b02622 100644 --- a/packages/ocap-kernel/package.json +++ b/packages/ocap-kernel/package.json @@ -68,11 +68,22 @@ }, "dependencies": { "@agoric/swingset-liveslots": "0.10.3-u21.0.1", + "@chainsafe/libp2p-noise": "^16.1.3", + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch", "@endo/errors": "^1.2.13", "@endo/import-bundle": "^1.5.2", "@endo/marshal": "^1.8.0", "@endo/pass-style": "^1.6.3", "@endo/promise-kit": "^1.1.13", + "@libp2p/bootstrap": "^11.0.35", + "@libp2p/circuit-relay-v2": "^3.2.11", + "@libp2p/crypto": "^5.1.1", + "@libp2p/identify": "^3.0.29", + "@libp2p/interface": "^2.9.0", + "@libp2p/peer-id": "^5.1.2", + "@libp2p/webrtc": "^5.2.12", + "@libp2p/websockets": "^9.2.10", + "@libp2p/webtransport": "^5.0.40", "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-store": "workspace:^", @@ -82,9 +93,14 @@ "@metamask/streams": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.2", + "@multiformats/multiaddr": "^12.3.0", "@ocap/kernel-platforms": "workspace:^", + "it-byte-stream": "^2.0.1", + "libp2p": "^2.8.5", + "multiformats": "^13.3.7", "ses": "^1.14.0", - "setimmediate": "^1.0.5" + "setimmediate": "^1.0.5", + "uint8arrays": "^5.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^5.0.1", diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index aac71ce8e8..7159582ccc 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -12,7 +12,7 @@ import { Kernel } from './Kernel.ts'; import type { VatId, VatConfig, - VatWorkerService, + PlatformServices, ClusterConfig, } from './types.ts'; import { VatHandle } from './VatHandle.ts'; @@ -65,7 +65,7 @@ const makeMockClusterConfig = (): ClusterConfig => ({ describe('Kernel', () => { let mockStream: DuplexStream; - let mockWorkerService: VatWorkerService; + let mockPlatformServices: PlatformServices; let launchWorkerMock: MockInstance; let terminateWorkerMock: MockInstance; let makeVatHandleMock: MockInstance; @@ -78,21 +78,21 @@ describe('Kernel', () => { dummyDispatch, ); - mockWorkerService = { + mockPlatformServices = { launch: async () => ({}) as unknown as DuplexStream, terminate: async () => undefined, terminateAll: async () => undefined, - } as unknown as VatWorkerService; + } as unknown as PlatformServices; launchWorkerMock = vi - .spyOn(mockWorkerService, 'launch') + .spyOn(mockPlatformServices, 'launch') .mockResolvedValue({ end: vi.fn() } as unknown as DuplexStream< JsonRpcMessage, JsonRpcMessage >); terminateWorkerMock = vi - .spyOn(mockWorkerService, 'terminate') + .spyOn(mockPlatformServices, 'terminate') .mockResolvedValue(undefined); vatHandles = []; @@ -121,7 +121,7 @@ describe('Kernel', () => { it('initializes the kernel without errors', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); expect(kernel).toBeDefined(); @@ -131,7 +131,7 @@ describe('Kernel', () => { const db = makeMapKernelDatabase(); db.kernelKVStore.set('foo', 'bar'); // Create with resetStorage should clear existing keys - await Kernel.make(mockStream, mockWorkerService, db, { + await Kernel.make(mockStream, mockPlatformServices, db, { resetStorage: true, }); expect(db.kernelKVStore.get('foo')).toBeUndefined(); @@ -142,7 +142,7 @@ describe('Kernel', () => { it('initializes the kernel store', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -161,22 +161,39 @@ describe('Kernel', () => { } as unknown as DuplexStream; await Kernel.make( customMockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); expect(drainHandler).toBeInstanceOf(Function); }); it('initializes and starts the kernel queue', async () => { - await Kernel.make(mockStream, mockWorkerService, mockKernelDatabase); + await Kernel.make(mockStream, mockPlatformServices, mockKernelDatabase); const queueInstance = mocks.KernelQueue.lastInstance; expect(queueInstance.run).toHaveBeenCalledTimes(1); }); + it('throws if the stream throws', async () => { + const streamError = new Error('Stream error'); + const throwingMockStream = { + drain: () => { + throw streamError; + }, + write: vi.fn().mockResolvedValue(undefined), + } as unknown as DuplexStream; + await expect( + Kernel.make( + throwingMockStream, + mockPlatformServices, + mockKernelDatabase, + ), + ).rejects.toThrow('Stream error'); + }); + it('recovers vats from persistent storage on startup', async () => { const db = makeMapKernelDatabase(); // Launch initial kernel and vat - const kernel1 = await Kernel.make(mockStream, mockWorkerService, db); + const kernel1 = await Kernel.make(mockStream, mockPlatformServices, db); await kernel1.launchSubcluster(makeSingleVatClusterConfig()); expect(kernel1.getVatIds()).toStrictEqual(['v1']); // Clear spies @@ -184,7 +201,7 @@ describe('Kernel', () => { makeVatHandleMock.mockClear(); // New kernel should recover existing vat immediately during make() - const kernel2 = await Kernel.make(mockStream, mockWorkerService, db); + const kernel2 = await Kernel.make(mockStream, mockPlatformServices, db); // The vat should be recovered immediately expect(launchWorkerMock).toHaveBeenCalledOnce(); @@ -197,7 +214,7 @@ describe('Kernel', () => { it('should reload all subclusters', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -216,7 +233,7 @@ describe('Kernel', () => { it('should handle empty subclusters gracefully', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const result = await kernel.reload(); @@ -228,7 +245,7 @@ describe('Kernel', () => { it('enqueues a message and returns the result', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -241,7 +258,7 @@ describe('Kernel', () => { it('launches a subcluster according to config', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -256,7 +273,7 @@ describe('Kernel', () => { it('throws an error for invalid configs', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); // @ts-expect-error Intentionally passing invalid config @@ -268,7 +285,7 @@ describe('Kernel', () => { it('throws an error when bootstrap vat name is invalid', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const invalidConfig = { @@ -287,7 +304,7 @@ describe('Kernel', () => { it('returns the bootstrap message result when bootstrap vat is specified', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -300,7 +317,7 @@ describe('Kernel', () => { it('terminates all vats in a subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = { @@ -325,7 +342,7 @@ describe('Kernel', () => { it('throws when terminating non-existent subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await expect(kernel.terminateSubcluster('non-existent')).rejects.toThrow( @@ -338,7 +355,7 @@ describe('Kernel', () => { it('returns subcluster by id', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -356,7 +373,7 @@ describe('Kernel', () => { it('returns undefined for non-existent subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); expect(kernel.getSubcluster('non-existent')).toBeUndefined(); @@ -367,7 +384,7 @@ describe('Kernel', () => { it('correctly identifies vat membership in subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -386,7 +403,7 @@ describe('Kernel', () => { it('returns all vat IDs in a subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = { @@ -411,7 +428,7 @@ describe('Kernel', () => { it('reloads a specific subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -429,7 +446,7 @@ describe('Kernel', () => { it('throws when reloading non-existent subcluster', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await expect(kernel.reloadSubcluster('non-existent')).rejects.toThrow( @@ -442,7 +459,7 @@ describe('Kernel', () => { it('clears the kernel storage', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const clearSpy = vi.spyOn(mockKernelDatabase, 'clear'); @@ -455,7 +472,7 @@ describe('Kernel', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); expect(kernel.getVats()).toStrictEqual([]); @@ -464,7 +481,7 @@ describe('Kernel', () => { it('returns vat information after adding vats', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeSingleVatClusterConfig(); @@ -483,7 +500,7 @@ describe('Kernel', () => { it('includes subcluster information for vats in subclusters', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -502,7 +519,7 @@ describe('Kernel', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); expect(kernel.getVatIds()).toStrictEqual([]); @@ -511,7 +528,7 @@ describe('Kernel', () => { it('returns the vat IDs after adding a vat', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -521,7 +538,7 @@ describe('Kernel', () => { it('returns multiple vat IDs after adding multiple vats', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -534,7 +551,7 @@ describe('Kernel', () => { it('returns the current kernel status', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const status = await kernel.getStatus(); @@ -547,7 +564,7 @@ describe('Kernel', () => { it('includes vats and subclusters in status', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); @@ -563,7 +580,7 @@ describe('Kernel', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -575,7 +592,7 @@ describe('Kernel', () => { it('adds multiple vats to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -590,7 +607,7 @@ describe('Kernel', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -604,7 +621,7 @@ describe('Kernel', () => { it('throws an error when deleting a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const nonExistentVatId: VatId = 'v9'; @@ -617,7 +634,7 @@ describe('Kernel', () => { it('throws an error when a vat terminate method throws', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -631,11 +648,11 @@ describe('Kernel', () => { describe('terminateAllVats()', () => { it('deletes all vats from the kernel without errors', async () => { const workerTerminateMock = vi - .spyOn(mockWorkerService, 'terminate') + .spyOn(mockPlatformServices, 'terminate') .mockResolvedValue(undefined); const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -653,11 +670,11 @@ describe('Kernel', () => { describe('stop()', () => { it('gracefully stops the kernel without terminating vats', async () => { const workerTerminateAllMock = vi - .spyOn(mockWorkerService, 'terminateAll') + .spyOn(mockPlatformServices, 'terminateAll') .mockResolvedValue(undefined); const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -674,7 +691,7 @@ describe('Kernel', () => { it('preserves vat state across multiple restarts', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -696,7 +713,7 @@ describe('Kernel', () => { it('restarts a vat', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -716,7 +733,7 @@ describe('Kernel', () => { it('throws error when restarting non-existent vat', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await expect(kernel.restartVat('v999')).rejects.toThrow(VatNotFoundError); @@ -727,7 +744,7 @@ describe('Kernel', () => { it('handles restart failure during termination', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -743,7 +760,7 @@ describe('Kernel', () => { it('handles restart failure during launch', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -756,7 +773,7 @@ describe('Kernel', () => { it('returns the original vat handle', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -770,7 +787,7 @@ describe('Kernel', () => { it('pings a vat without errors when the vat exists', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -783,7 +800,7 @@ describe('Kernel', () => { it('throws an error when pinging a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const nonExistentVatId: VatId = 'v9'; @@ -795,7 +812,7 @@ describe('Kernel', () => { it('propagates errors from the vat ping method', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -809,7 +826,11 @@ describe('Kernel', () => { it('terminates all vats and resets kernel state', async () => { const mockDb = makeMapKernelDatabase(); const clearSpy = vi.spyOn(mockDb, 'clear'); - const kernel = await Kernel.make(mockStream, mockWorkerService, mockDb); + const kernel = await Kernel.make( + mockStream, + mockPlatformServices, + mockDb, + ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); await kernel.reset(); expect(clearSpy).toHaveBeenCalled(); @@ -820,9 +841,12 @@ describe('Kernel', () => { const mockDb = makeMapKernelDatabase(); const logger = new Logger('test'); const logErrorSpy = vi.spyOn(logger, 'error'); - const kernel = await Kernel.make(mockStream, mockWorkerService, mockDb, { - logger, - }); + const kernel = await Kernel.make( + mockStream, + mockPlatformServices, + mockDb, + { logger }, + ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); vi.spyOn(mockDb, 'clear').mockImplementationOnce(() => { @@ -840,7 +864,7 @@ describe('Kernel', () => { it('reflect when an object is revoked', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -852,7 +876,7 @@ describe('Kernel', () => { it('throws when revoking a promise', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); await kernel.launchSubcluster(makeSingleVatClusterConfig()); @@ -864,13 +888,13 @@ describe('Kernel', () => { it('pins and unpins a vat root correctly', async () => { const kernel = await Kernel.make( mockStream, - mockWorkerService, + mockPlatformServices, mockKernelDatabase, ); const config = makeSingleVatClusterConfig(); await kernel.launchSubcluster(config); // Pinning existing vat root should return the kref - expect(kernel.pinVatRoot('v1')).toBe('ko1'); + expect(kernel.pinVatRoot('v1')).toBe('ko3'); // Pinning non-existent vat should throw expect(() => kernel.pinVatRoot('v2')).toThrow(VatNotFoundError); // Unpinning existing vat root should succeed diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 10e1bec77b..a707975adf 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -1,3 +1,4 @@ +import { Far } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; import { VatAlreadyExistsError, @@ -17,23 +18,34 @@ import type { JsonRpcResponse } from '@metamask/utils'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; +import { initRemoteComms, parseOcapURL } from './remote-comms.ts'; +import { RemoteHandle } from './RemoteHandle.ts'; import { kernelHandlers } from './rpc/index.ts'; import type { PingVatResult } from './rpc/index.ts'; -import { kslot, kser, kunser } from './services/kernel-marshal.ts'; +import { kslot, kser, kunser, krefOf } from './services/kernel-marshal.ts'; import type { SlotValue } from './services/kernel-marshal.ts'; import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; import type { VatId, + RemoteId, + EndpointId, KRef, - VatWorkerService, + PlatformServices, ClusterConfig, VatConfig, KernelStatus, Subcluster, Message, + EndpointHandle, + RemoteComms, +} from './types.ts'; +import { + ROOT_OBJECT_VREF, + isClusterConfig, + isVatId, + isRemoteId, } from './types.ts'; -import { ROOT_OBJECT_VREF, isClusterConfig } from './types.ts'; import { Fail, assert } from './utils/assert.ts'; import { VatHandle } from './VatHandle.ts'; @@ -43,6 +55,9 @@ type KernelService = { service: object; }; +// XXX See #egregiousDebugHack below +let foolTheCompiler: string = 'start'; + export class Kernel { /** Command channel from the controlling console/browser extension/test driver */ readonly #commandStream: DuplexStream; @@ -52,8 +67,17 @@ export class Kernel { /** Currently running vats, by ID */ readonly #vats: Map; - /** Service to spawn workers (in iframes) for vats to run in */ - readonly #vatWorkerService: VatWorkerService; + /** Currently active remote kernel connections, by ID */ + readonly #remotes: Map; + + /** Currently active remote kernel connections, by remote Peer ID */ + readonly #remotesByPeer: Map; + + /** + * Service to to things the kernel worker can't do: network communications + * and spawning workers (in iframes) for vats to run in + */ + readonly #platformServices: PlatformServices; /** Storage holding the kernel's own persistent state */ readonly #kernelStore: KernelStore; @@ -72,11 +96,14 @@ export class Kernel { readonly #kernelServicesByObject: Map = new Map(); + /** My network access */ + #remoteComms: RemoteComms | undefined; + /** * Construct a new kernel instance. * * @param commandStream - Command channel from whatever external software is driving the kernel. - * @param vatWorkerService - Service to create a worker in which a new vat can run. + * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. * @param options.resetStorage - If true, the storage will be cleared. @@ -85,19 +112,28 @@ export class Kernel { // eslint-disable-next-line no-restricted-syntax private constructor( commandStream: DuplexStream, - vatWorkerService: VatWorkerService, + platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { resetStorage?: boolean; logger?: Logger; } = {}, ) { + // XXX See #egregiousDebugHack below + foolTheCompiler = 'nope'; + this.#commandStream = commandStream; this.#rpcService = new RpcService(kernelHandlers, {}); this.#vats = new Map(); - this.#vatWorkerService = vatWorkerService; + this.#remotes = new Map(); + this.#remotesByPeer = new Map(); + this.#platformServices = platformServices; this.#logger = options.logger ?? new Logger('ocap-kernel'); this.#kernelStore = makeKernelStore(kernelDatabase); + if (!this.#kernelStore.kv.get('initialized')) { + this.#kernelStore.kv.set('initialized', 'true'); + } + if (options.resetStorage) { this.#resetKernelState(); } @@ -108,9 +144,37 @@ export class Kernel { this.#kernelRouter = new KernelRouter( this.#kernelStore, this.#kernelQueue, - this.#getVat.bind(this), + this.#getEndpoint.bind(this), this.#invokeKernelService.bind(this), + this.#logger, + ); + + const ocapURLIssuerService = Far('serviceObject', { + issue: async (obj: SlotValue): Promise => { + let kref: string; + try { + kref = krefOf(obj); + } catch { + throw Error(`argument must be a remotable`); + } + return await this.#issueOcapURL(kref); + }, + }); + this.registerKernelServiceObject( + 'ocapURLIssuerService', + ocapURLIssuerService, + ); + + const ocapURLRedemptionService = Far('serviceObject', { + redeem: async (url: string): Promise => { + return kslot(await this.#redeemOcapURL(url)); + }, + }); + this.registerKernelServiceObject( + 'ocapURLRedemptionService', + ocapURLRedemptionService, ); + harden(this); } @@ -118,7 +182,7 @@ export class Kernel { * Create a new kernel instance. * * @param commandStream - Command channel from whatever external software is driving the kernel. - * @param vatWorkerService - Service to create a worker in which a new vat can run. + * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. * @param options.resetStorage - If true, the storage will be cleared. @@ -127,7 +191,7 @@ export class Kernel { */ static async make( commandStream: DuplexStream, - vatWorkerService: VatWorkerService, + platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { resetStorage?: boolean; @@ -136,7 +200,7 @@ export class Kernel { ): Promise { const kernel = new Kernel( commandStream, - vatWorkerService, + platformServices, kernelDatabase, options, ); @@ -144,6 +208,11 @@ export class Kernel { return kernel; } + async #handleRemoteMessage(from: string, message: string): Promise { + const remote = this.#remoteFor(from); + return await remote.handleRemoteMessage(message); + } + /** * Start the kernel running. Sets it up to actually receive command messages * and then begin processing the run queue. @@ -182,6 +251,39 @@ export class Kernel { }); } + #getRemoteComms(): RemoteComms { + if (this.#remoteComms) { + return this.#remoteComms; + } + throw Error(`remote comms not initialized`); + } + + async initRemoteComms(): Promise { + this.#remoteComms = await initRemoteComms( + this.#kernelStore, + this.#platformServices, + this.#handleRemoteMessage.bind(this), + ); + } + + async sendRemoteMessage(peerId: string, message: string): Promise { + await this.#getRemoteComms().sendRemoteMessage(peerId, message); + } + + async #redeemOcapURL(url: string): Promise { + const { host } = parseOcapURL(url); + if (host === this.#getRemoteComms().getPeerId()) { + return this.#getRemoteComms().redeemLocalOcapURL(url); + } + // XXX TODO ignoring hints for now, just use known relay + const remote = this.#remoteFor(host); + return remote.redeemOcapURL(url); + } + + async #issueOcapURL(kref: KRef): Promise { + return this.#getRemoteComms().issueOcapURL(kref); + } + /** * Handle messages received over the command channel. * @@ -214,7 +316,7 @@ export class Kernel { } /** - * Launches a new vat. + * Launch a new vat. * * @param vatConfig - Configuration for the new vat. * @param subclusterId - The ID of the subcluster to launch the vat in. Optional. @@ -224,7 +326,10 @@ export class Kernel { const vatId = this.#kernelStore.getNextVatId(); await this.#runVat(vatId, vatConfig); this.#kernelStore.initEndpoint(vatId); - const rootRef = this.#kernelStore.exportFromVat(vatId, ROOT_OBJECT_VREF); + const rootRef = this.#kernelStore.exportFromEndpoint( + vatId, + ROOT_OBJECT_VREF, + ); this.#kernelStore.setVatConfig(vatId, vatConfig); if (subclusterId) { this.#kernelStore.addSubclusterVat(subclusterId, vatId); @@ -232,6 +337,41 @@ export class Kernel { return rootRef; } + /** + * Set up bookkeeping for a newly established remote connection. + * + * @param peerId - Peer ID of the kernel at the other end of the connection. + * + * @returns the RemoteHandle that was set up. + */ + #establishRemote(peerId: string): RemoteHandle { + const remoteComms = this.#getRemoteComms(); + const remoteId = this.#kernelStore.getNextRemoteId(); + const remote = RemoteHandle.make({ + remoteId, + peerId, + kernelStore: this.#kernelStore, + kernelQueue: this.#kernelQueue, + remoteComms, + }); + this.#remotes.set(remoteId, remote); + this.#remotesByPeer.set(peerId, remote); + return remote; + } + + /** + * Get or create a RemoteHandle for a given peer ID. + * + * @param peerId - The libp2p peer for which a handle is sought. + * + * @returns an existing or new RemoteHandle to communicate with `peerId`. + */ + #remoteFor(peerId: string): RemoteHandle { + const remote = + this.#remotesByPeer.get(peerId) ?? this.#establishRemote(peerId); + return remote; + } + /** * Start a new or resurrected vat running. * @@ -242,7 +382,7 @@ export class Kernel { if (this.#vats.has(vatId)) { throw new VatAlreadyExistsError(vatId); } - const stream = await this.#vatWorkerService.launch(vatId, vatConfig); + const stream = await this.#platformServices.launch(vatId, vatConfig); const { kernelStream: vatStream, loggerStream } = splitLoggerStream(stream); const vatLogger = this.#logger.subLogger({ tags: [vatId] }); vatLogger.injectStream( @@ -474,7 +614,7 @@ export class Kernel { terminationError = new VatDeletedError(vatId); } - await this.#vatWorkerService + await this.#platformServices .terminate(vatId, terminationError) .catch(this.#logger.error); await vat.terminate(terminating, terminationError); @@ -502,6 +642,31 @@ export class Kernel { this.#kernelStore.clear(); } + #getEndpoint(endpointId: EndpointId): EndpointHandle { + if (isVatId(endpointId)) { + return this.#getVat(endpointId); + } + if (isRemoteId(endpointId)) { + return this.#getRemote(endpointId); + } + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw Error(`invalid endpoint ID ${endpointId}`); + } + + /** + * Get a remote. + * + * @param remoteId - The ID of the remote. + * @returns the remote's RemoteHandle. + */ + #getRemote(remoteId: RemoteId): RemoteHandle { + const remote = this.#remotes.get(remoteId); + if (remote === undefined) { + throw Error(`remote not found ${remoteId}`); + } + return remote; + } + /** * Get a vat. * @@ -636,8 +801,17 @@ export class Kernel { * This is for debugging purposes only. */ #resetKernelState(): void { + // XXX special case hack so that network address survives restart when testing + const keySeed = this.#kernelStore.kv.get('keySeed'); + const peerId = this.#kernelStore.kv.get('peerId'); + const ocapURLKey = this.#kernelStore.kv.get('ocapURLKey'); this.#kernelStore.clear(); this.#kernelStore.reset(); + if (keySeed && peerId && ocapURLKey) { + this.#kernelStore.kv.set('keySeed', keySeed); + this.#kernelStore.kv.set('peerId', peerId); + this.#kernelStore.kv.set('ocapURLKey', ocapURLKey); + } } /** @@ -682,6 +856,29 @@ export class Kernel { } } + async #egregiousDebugHack(): Promise { + if (this.#remoteComms) { + // We deliberately use `let` rather than `const` for the URL string. It is + // stored in a variable specifically to enable it to be modified in the + // debugger. Unfortunately, we have to jump through some hoops to prevent + // the compiler's control flow analysis from removing it -- and the entire + // subsequent `if` block that tests it! -- from the generated code. To + // this end, the variable `foolTheCompiler` is defined as a global + // initialized with one value and then deliberately and gratuitously + // modified in the constructor to a different value, because TypeScript + // lacks anything like a `volatile` declaration. + + // eslint-disable-next-line prefer-const + let url: string = 'nope'; + // eslint-disable-next-line no-debugger + debugger; + + if (url !== foolTheCompiler) { + await this.queueMessage('ko3', 'doRunRun', [url]); + } + } + } + /** * Gracefully stop the kernel without deleting vats. */ @@ -689,7 +886,7 @@ export class Kernel { await this.#kernelQueue.waitForCrank(); this.#logger.info('Stopping kernel gracefully...'); await this.#commandStream.end(); - await this.#vatWorkerService.terminateAll(); + await this.#platformServices.terminateAll(); this.#logger.info('Kernel stopped gracefully'); } @@ -702,10 +899,19 @@ export class Kernel { // wait for all vats to be cleaned up } this.#kernelStore.collectGarbage(); + + // XXX REMOVE THIS Stupid debug trick: In order to exercise the remote + // connection machinery (in service of attempting to get said machinery to + // actually work), we need a way during debugging to trigger the kernel to + // try to set up and use a remote connection. The control panel's 'Collect + // Garbage' button turns out to be a super convenient one-click "hey kernel + // please do something" hook to parasitize for this purpose. + this.#egregiousDebugHack().catch(() => undefined); } registerKernelServiceObject(name: string, service: object): void { const kref = this.#kernelStore.initKernelObject('kernel'); + this.#kernelStore.pinObject(kref); const kernelService = { name, kref, service }; this.#kernelServicesByName.set(name, kernelService); this.#kernelServicesByObject.set(kref, kernelService); diff --git a/packages/ocap-kernel/src/KernelQueue.test.ts b/packages/ocap-kernel/src/KernelQueue.test.ts index f13b1a1e9b..0120673f8e 100644 --- a/packages/ocap-kernel/src/KernelQueue.test.ts +++ b/packages/ocap-kernel/src/KernelQueue.test.ts @@ -245,12 +245,12 @@ describe('KernelQueue', () => { describe('enqueueNotify', () => { it('creates a notify item and adds it to the run queue', () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; - kernelQueue.enqueueNotify(vatId, kpid); + kernelQueue.enqueueNotify(endpointId, kpid); expect(kernelStore.enqueueRun).toHaveBeenCalledWith({ type: 'notify', - vatId, + endpointId, kpid, }); expect(kernelStore.incrementRefCount).toHaveBeenCalledWith( @@ -262,7 +262,7 @@ describe('KernelQueue', () => { describe('resolvePromises', () => { it('resolves kernel promises and notifies subscribers', () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const resolution: VatOneResolution = [ kpid, @@ -272,13 +272,13 @@ describe('KernelQueue', () => { (kernelStore.getKernelPromise as unknown as MockInstance).mockReturnValue( { state: 'unresolved', - decider: vatId, + decider: endpointId, subscribers: ['v2', 'v3'], }, ); const resolveHandler = vi.fn(); kernelQueue.subscriptions.set(kpid, resolveHandler); - kernelQueue.resolvePromises(vatId, [resolution]); + kernelQueue.resolvePromises(endpointId, [resolution]); expect(kernelStore.incrementRefCount).toHaveBeenCalledWith( kpid, 'resolve|kpid', @@ -289,12 +289,12 @@ describe('KernelQueue', () => { ); expect(kernelStore.enqueueRun).toHaveBeenCalledWith({ type: 'notify', - vatId: 'v2', + endpointId: 'v2', kpid, }); expect(kernelStore.enqueueRun).toHaveBeenCalledWith({ type: 'notify', - vatId: 'v3', + endpointId: 'v3', kpid, }); expect(kernelStore.resolveKernelPromise).toHaveBeenCalledWith( @@ -325,9 +325,9 @@ describe('KernelQueue', () => { ); const resolveHandler = vi.fn(); kernelQueue.subscriptions.set(kpid, resolveHandler); - const insistVatIdSpy = vi.spyOn(types, 'insistVatId'); + const insistEndpointIdSpy = vi.spyOn(types, 'insistEndpointId'); kernelQueue.resolvePromises(undefined, [resolution]); - expect(insistVatIdSpy).not.toHaveBeenCalled(); + expect(insistEndpointIdSpy).not.toHaveBeenCalled(); expect(kernelStore.incrementRefCount).toHaveBeenCalledWith( kpid, 'resolve|kpid', @@ -338,7 +338,7 @@ describe('KernelQueue', () => { ); expect(kernelStore.enqueueRun).toHaveBeenCalledWith({ type: 'notify', - vatId: 'v2', + endpointId: 'v2', kpid, }); expect(kernelStore.resolveKernelPromise).toHaveBeenCalledWith( @@ -348,11 +348,11 @@ describe('KernelQueue', () => { ); expect(resolveHandler).toHaveBeenCalledWith(resolution[2]); expect(kernelQueue.subscriptions.has(kpid)).toBe(false); - insistVatIdSpy.mockRestore(); + insistEndpointIdSpy.mockRestore(); }); it('handles promises with no subscribers', () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kpNoSubscribers'; const resolution: VatOneResolution = [ kpid, @@ -362,13 +362,13 @@ describe('KernelQueue', () => { (kernelStore.getKernelPromise as unknown as MockInstance).mockReturnValue( { state: 'unresolved', - decider: vatId, + decider: endpointId, subscribers: [], }, ); const resolveHandler = vi.fn(); kernelQueue.subscriptions.set(kpid, resolveHandler); - kernelQueue.resolvePromises(vatId, [resolution]); + kernelQueue.resolvePromises(endpointId, [resolution]); expect(kernelStore.enqueueRun).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'notify' }), ); @@ -382,7 +382,7 @@ describe('KernelQueue', () => { }); it('throws error if a promise is already resolved', () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const resolution: VatOneResolution = [ kpid, @@ -392,17 +392,17 @@ describe('KernelQueue', () => { (kernelStore.getKernelPromise as unknown as MockInstance).mockReturnValue( { state: 'fulfilled', - decider: vatId, + decider: endpointId, }, ); - expect(() => kernelQueue.resolvePromises(vatId, [resolution])).toThrow( - '"kp123" was already resolved', - ); + expect(() => + kernelQueue.resolvePromises(endpointId, [resolution]), + ).toThrow('"kp123" was already resolved'); }); it('throws error if the resolver is not the decider', () => { - const vatId = 'v1'; - const wrongVatId = 'v2'; + const endpointId = 'v1'; + const wrongEndpointId = 'v2'; const kpid = 'kp123'; const resolution: VatOneResolution = [ kpid, @@ -412,10 +412,12 @@ describe('KernelQueue', () => { (kernelStore.getKernelPromise as unknown as MockInstance).mockReturnValue( { state: 'unresolved', - decider: wrongVatId, + decider: wrongEndpointId, }, ); - expect(() => kernelQueue.resolvePromises(vatId, [resolution])).toThrow( + expect(() => + kernelQueue.resolvePromises(endpointId, [resolution]), + ).toThrow( '"v1" not permitted to resolve "kp123" because "its decider is v2"', ); }); diff --git a/packages/ocap-kernel/src/KernelQueue.ts b/packages/ocap-kernel/src/KernelQueue.ts index d8a5e6db90..2d62e1a520 100644 --- a/packages/ocap-kernel/src/KernelQueue.ts +++ b/packages/ocap-kernel/src/KernelQueue.ts @@ -5,7 +5,7 @@ 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/index.ts'; -import { insistVatId } from './types.ts'; +import { insistEndpointId } from './types.ts'; import type { CrankResults, KRef, @@ -14,6 +14,7 @@ import type { RunQueueItemNotify, RunQueueItemSend, VatId, + EndpointId, } from './types.ts'; import { Fail } from './utils/assert.ts'; @@ -143,7 +144,7 @@ export class KernelQueue { } /** - * Queue a message to be delivered from the kernel to an object in a vat. + * Queue a message to be delivered from the kernel to an object in an endpoint. * * @param target - The object to which the message is directed. * @param method - The method to be invoked. @@ -170,7 +171,7 @@ export class KernelQueue { } /** - * Enqueue a send message to be delivered to a vat. + * Enqueue a send message to be delivered to an endpoint. * * @param target - The object to which the message is directed. * @param message - The message to be delivered. @@ -192,14 +193,14 @@ export class KernelQueue { } /** - * Enqueue for delivery a notification to a vat about the resolution of a + * Enqueue for delivery a notification to an endpoint about the resolution of a * promise. * - * @param vatId - The vat that will be notified. + * @param endpointId - The endpoint that will be notified. * @param kpid - The promise of interest. */ - enqueueNotify(vatId: VatId, kpid: KRef): void { - const notifyItem: RunQueueItemNotify = { type: 'notify', vatId, kpid }; + enqueueNotify(endpointId: EndpointId, kpid: KRef): void { + const notifyItem: RunQueueItemNotify = { type: 'notify', endpointId, kpid }; this.#enqueueRun(notifyItem); // Increment reference count for the promise being notified about this.#kernelStore.incrementRefCount(kpid, 'notify'); @@ -217,17 +218,17 @@ export class KernelQueue { } /** - * Process a set of promise resolutions coming from a vat. + * Process a set of promise resolutions coming from an endpoint. * - * @param vatId - The vat doing the resolving, if there is one. + * @param endpointId - The endpoint doing the resolving, if there is one. * @param resolutions - One or more resolutions, to be processed as a group. */ resolvePromises( - vatId: VatId | undefined, + endpointId: EndpointId | undefined, resolutions: VatOneResolution[], ): void { - if (vatId && vatId !== 'kernel') { - insistVatId(vatId); + if (endpointId && endpointId !== 'kernel') { + insistEndpointId(endpointId); } for (const resolution of resolutions) { const [kpid, rejected, dataRaw] = resolution; @@ -243,9 +244,9 @@ export class KernelQueue { if (state !== 'unresolved') { Fail`${kpid} was already resolved`; } - if (decider !== vatId) { + if (decider !== endpointId) { const why = decider ? `its decider is ${decider}` : `it has no decider`; - Fail`${vatId} not permitted to resolve ${kpid} because ${why}`; + Fail`${endpointId} not permitted to resolve ${kpid} because ${why}`; } if (!subscribers) { throw Fail`${kpid} subscribers not set`; diff --git a/packages/ocap-kernel/src/KernelRouter.test.ts b/packages/ocap-kernel/src/KernelRouter.test.ts index b596dbfdd3..8c7953c29d 100644 --- a/packages/ocap-kernel/src/KernelRouter.test.ts +++ b/packages/ocap-kernel/src/KernelRouter.test.ts @@ -11,11 +11,11 @@ import type { RunQueueItemNotify, RunQueueItemGCAction, RunQueueItemBringOutYourDead, - VatId, + EndpointId, GCRunQueueType, CrankResults, + EndpointHandle, } from './types.ts'; -import type { VatHandle } from './VatHandle.ts'; // Define Message type for tests that matches the required structure type Message = { @@ -27,25 +27,25 @@ describe('KernelRouter', () => { // Mock dependencies let kernelStore: KernelStore; let kernelQueue: KernelQueue; - let getVat: (vatId: VatId) => VatHandle; - let vatHandle: VatHandle; + let getEndpoint: (endpointId: EndpointId) => EndpointHandle; + let endpointHandle: EndpointHandle; let kernelRouter: KernelRouter; beforeEach(() => { - // Mock VatHandle with more detailed return values + // Mock EndpointHandle with more detailed return values const mockCrankResults: CrankResults = { didDelivery: 'v1' }; - vatHandle = { + endpointHandle = { deliverMessage: vi.fn().mockResolvedValue(mockCrankResults), deliverNotify: vi.fn().mockResolvedValue(mockCrankResults), deliverDropExports: vi.fn().mockResolvedValue(mockCrankResults), deliverRetireExports: vi.fn().mockResolvedValue(mockCrankResults), deliverRetireImports: vi.fn().mockResolvedValue(mockCrankResults), deliverBringOutYourDead: vi.fn().mockResolvedValue(mockCrankResults), - } as unknown as VatHandle; + } as unknown as EndpointHandle; - // Mock getVat function - getVat = vi.fn().mockReturnValue(vatHandle); + // Mock getEndpoint function + getEndpoint = vi.fn().mockReturnValue(endpointHandle); // Mock KernelStore kernelStore = { @@ -54,19 +54,19 @@ describe('KernelRouter', () => { getKernelPromise: vi.fn(), decrementRefCount: vi.fn(), setPromiseDecider: vi.fn(), - translateRefKtoV: vi.fn( - (_vatId: string, kref: string) => `translated-${kref}`, + translateRefKtoE: vi.fn( + (_endpointId: string, kref: string) => `translated-${kref}`, ) as unknown as MockInstance, - translateMessageKtoV: vi.fn( - (_vatId: string, message: SwingsetMessage) => + translateMessageKtoE: vi.fn( + (_endpointId: string, message: SwingsetMessage) => message as unknown as SwingsetMessage, ) as unknown as MockInstance, enqueuePromiseMessage: vi.fn(), erefToKref: vi.fn() as unknown as MockInstance, krefToEref: vi.fn() as unknown as MockInstance, getKpidsToRetire: vi.fn().mockReturnValue([]), - translateCapDataKtoV: vi.fn(), - krefsToExistingErefs: vi.fn((_vatId: string, krefs: string[]) => + translateCapDataKtoE: vi.fn(), + krefsToExistingErefs: vi.fn((_endpointId: string, krefs: string[]) => krefs.map((kref: string) => `translated-${kref}`), ) as unknown as MockInstance, createCrankSavepoint: vi.fn(), @@ -77,27 +77,34 @@ describe('KernelRouter', () => { resolvePromises: vi.fn(), } as unknown as KernelQueue; + const mockInvokeKernelService = vi.fn(); + // Create the router to test - kernelRouter = new KernelRouter(kernelStore, kernelQueue, getVat); + kernelRouter = new KernelRouter( + kernelStore, + kernelQueue, + getEndpoint, + mockInvokeKernelService, + ); }); describe('deliver', () => { describe('send', () => { it('delivers a send message to a vat with an object target and returns crank results', async () => { // Setup the kernel store to return an owner for the target - const vatId = 'v1'; + const endpointId = 'v1'; const target = 'ko123'; (kernelStore.getOwner as unknown as MockInstance).mockReturnValueOnce( - vatId, + endpointId, ); // Create a mock crank result that the vat will return const mockCrankResults: CrankResults = { - didDelivery: vatId, + didDelivery: endpointId, abort: false, }; ( - vatHandle.deliverMessage as unknown as MockInstance + endpointHandle.deliverMessage as unknown as MockInstance ).mockResolvedValueOnce(mockCrankResults); // Create a send message @@ -114,8 +121,8 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(sendItem); // Verify the message was delivered to the vat and results returned - expect(getVat).toHaveBeenCalledWith(vatId); - expect(vatHandle.deliverMessage).toHaveBeenCalledWith( + expect(getEndpoint).toHaveBeenCalledWith(endpointId); + expect(endpointHandle.deliverMessage).toHaveBeenCalledWith( `translated-${target}`, message, ); @@ -158,8 +165,8 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(sendItem); // Verify the message was not delivered to any vat and resources were cleaned up - expect(getVat).not.toHaveBeenCalled(); - expect(vatHandle.deliverMessage).not.toHaveBeenCalled(); + expect(getEndpoint).not.toHaveBeenCalled(); + expect(endpointHandle.deliverMessage).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Verify refcounts were decremented @@ -215,8 +222,8 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(sendItem); // Verify the message was not delivered to any vat and resources were cleaned up - expect(getVat).not.toHaveBeenCalled(); - expect(vatHandle.deliverMessage).not.toHaveBeenCalled(); + expect(getEndpoint).not.toHaveBeenCalled(); + expect(endpointHandle.deliverMessage).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Verify refcounts were decremented @@ -272,8 +279,8 @@ describe('KernelRouter', () => { message, ); // Verify no vat interaction occurred - expect(getVat).not.toHaveBeenCalled(); - expect(vatHandle.deliverMessage).not.toHaveBeenCalled(); + expect(getEndpoint).not.toHaveBeenCalled(); + expect(endpointHandle.deliverMessage).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Verify that no refcount decrementation happened since we're requeuing @@ -308,8 +315,8 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(sendItem); // Message should be splatted, not delivered - expect(getVat).not.toHaveBeenCalled(); - expect(vatHandle.deliverMessage).not.toHaveBeenCalled(); + expect(getEndpoint).not.toHaveBeenCalled(); + expect(endpointHandle.deliverMessage).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Verify the result promise was rejected @@ -350,8 +357,8 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(sendItem); // Message should be splatted, not delivered - expect(getVat).not.toHaveBeenCalled(); - expect(vatHandle.deliverMessage).not.toHaveBeenCalled(); + expect(getEndpoint).not.toHaveBeenCalled(); + expect(endpointHandle.deliverMessage).not.toHaveBeenCalled(); expect(result).toBeUndefined(); // Verify the result promise was rejected with the same reason @@ -366,11 +373,11 @@ describe('KernelRouter', () => { describe('notify', () => { it('delivers a notify to a vat and returns crank results', async () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const notifyItem: RunQueueItemNotify = { type: 'notify', - vatId, + endpointId, kpid, }; @@ -407,17 +414,19 @@ describe('KernelRouter', () => { }); // Mock crank results - const mockCrankResults: CrankResults = { didDelivery: vatId }; + const mockCrankResults: CrankResults = { didDelivery: endpointId }; ( - vatHandle.deliverNotify as unknown as MockInstance + endpointHandle.deliverNotify as unknown as MockInstance ).mockResolvedValueOnce(mockCrankResults); // Deliver the notify const result = await kernelRouter.deliver(notifyItem); // Verify the notification was delivered to the vat - expect(getVat).toHaveBeenCalledWith(vatId); - expect(vatHandle.deliverNotify).toHaveBeenCalledWith(expect.any(Array)); + expect(getEndpoint).toHaveBeenCalledWith(endpointId); + expect(endpointHandle.deliverNotify).toHaveBeenCalledWith( + expect.any(Array), + ); expect(kernelStore.decrementRefCount).toHaveBeenCalledWith( kpid, 'deliver|notify', @@ -426,11 +435,11 @@ describe('KernelRouter', () => { }); it('returns didDelivery when promise is not in vat clist', async () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const notifyItem: RunQueueItemNotify = { type: 'notify', - vatId, + endpointId, kpid, }; @@ -454,16 +463,16 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(notifyItem); // Verify no notification was delivered to the vat - expect(vatHandle.deliverNotify).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ didDelivery: vatId }); + expect(endpointHandle.deliverNotify).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ didDelivery: endpointId }); }); it('returns didDelivery when no kpids to retire', async () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const notifyItem: RunQueueItemNotify = { type: 'notify', - vatId, + endpointId, kpid, }; @@ -492,16 +501,16 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(notifyItem); // Verify no notification was delivered to the vat - expect(vatHandle.deliverNotify).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ didDelivery: vatId }); + expect(endpointHandle.deliverNotify).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ didDelivery: endpointId }); }); it('throws if notification is for an unresolved promise', async () => { - const vatId = 'v1'; + const endpointId = 'v1'; const kpid = 'kp123'; const notifyItem: RunQueueItemNotify = { type: 'notify', - vatId, + endpointId, kpid, }; @@ -528,19 +537,19 @@ describe('KernelRouter', () => { ])( 'delivers %s to a vat and returns crank results', async (actionType, deliverMethod) => { - const vatId = 'v1'; + const endpointId = 'v1'; const krefs = ['ko1', 'ko2']; const gcAction: RunQueueItemGCAction = { type: actionType as GCRunQueueType, - vatId, + endpointId, krefs, }; // Mock crank results - const mockCrankResults: CrankResults = { didDelivery: vatId }; + const mockCrankResults: CrankResults = { didDelivery: endpointId }; ( - vatHandle[ - deliverMethod as keyof VatHandle + endpointHandle[ + deliverMethod as keyof EndpointHandle ] as unknown as MockInstance ).mockResolvedValueOnce(mockCrankResults); @@ -548,9 +557,9 @@ describe('KernelRouter', () => { const result = await kernelRouter.deliver(gcAction); // Verify the action was delivered to the vat - expect(getVat).toHaveBeenCalledWith(vatId); + expect(getEndpoint).toHaveBeenCalledWith(endpointId); expect( - vatHandle[deliverMethod as keyof VatHandle], + endpointHandle[deliverMethod as keyof EndpointHandle], ).toHaveBeenCalledWith(krefs.map((kref) => `translated-${kref}`)); expect(result).toStrictEqual(mockCrankResults); }, @@ -559,24 +568,24 @@ describe('KernelRouter', () => { describe('bringOutYourDead', () => { it('delivers bringOutYourDead to a vat and returns crank results', async () => { - const vatId = 'v1'; + const endpointId = 'v1'; const bringOutYourDeadItem: RunQueueItemBringOutYourDead = { type: 'bringOutYourDead', - vatId, + endpointId, }; // Mock crank results - const mockCrankResults: CrankResults = { didDelivery: vatId }; + const mockCrankResults: CrankResults = { didDelivery: endpointId }; ( - vatHandle.deliverBringOutYourDead as unknown as MockInstance + endpointHandle.deliverBringOutYourDead as unknown as MockInstance ).mockResolvedValueOnce(mockCrankResults); // Deliver the bringOutYourDead action const result = await kernelRouter.deliver(bringOutYourDeadItem); - // Verify the action was delivered to the vat - expect(getVat).toHaveBeenCalledWith(vatId); - expect(vatHandle.deliverBringOutYourDead).toHaveBeenCalled(); + // Verify the action was delivered to the endpoint + expect(getEndpoint).toHaveBeenCalledWith(endpointId); + expect(endpointHandle.deliverBringOutYourDead).toHaveBeenCalled(); expect(result).toStrictEqual(mockCrankResults); }); }); diff --git a/packages/ocap-kernel/src/KernelRouter.ts b/packages/ocap-kernel/src/KernelRouter.ts index 366a7a578c..e3d40b2479 100644 --- a/packages/ocap-kernel/src/KernelRouter.ts +++ b/packages/ocap-kernel/src/KernelRouter.ts @@ -9,7 +9,8 @@ import { extractSingleRef } from './store/utils/extract-ref.ts'; import { parseRef } from './store/utils/parse-ref.ts'; import { isPromiseRef } from './store/utils/promise-ref.ts'; import type { - VatId, + EndpointId, + EndpointHandle, KRef, Message, RunQueueItem, @@ -19,19 +20,18 @@ import type { RunQueueItemGCAction, CrankResults, } from './types.ts'; -import { insistVatId, insistMessage } from './types.ts'; +import { insistEndpointId, insistMessage } from './types.ts'; import { assert, Fail } from './utils/assert.ts'; -import { VatHandle } from './VatHandle.ts'; type MessageRoute = { - vatId?: VatId; + endpointId?: EndpointId; target: KRef; } | null; /** - * The KernelRouter is responsible for routing messages to the correct vat. + * The KernelRouter is responsible for routing messages to the correct endpoint. * - * This class is responsible for routing messages to the correct vat, including + * This class is responsible for routing messages to the correct endpoint, including * sending messages, resolving promises, and dropping imports. */ export class KernelRouter { @@ -41,8 +41,8 @@ export class KernelRouter { /** The kernel's queue. */ readonly #kernelQueue: KernelQueue; - /** A function that returns a vat handle for a given vat id. */ - readonly #getVat: (vatId: VatId) => VatHandle; + /** A function that returns an endpoint handle for a given endpoint id. */ + readonly #getEndpoint: (endpointId: EndpointId) => EndpointHandle; /** A function that invokes a method on a kernel service. */ readonly #invokeKernelService: ( @@ -58,20 +58,20 @@ export class KernelRouter { * * @param kernelStore - The kernel's store. * @param kernelQueue - The kernel's queue. - * @param getVat - A function that returns a vat handle for a given vat id. + * @param getEndpoint - A function that returns an endpoint handle for a given endpoint id. * @param invokeKernelService - A function that calls a method on a kernel service object. * @param logger - The logger. If not provided, no logging will be done. */ constructor( kernelStore: KernelStore, kernelQueue: KernelQueue, - getVat: (vatId: VatId) => VatHandle, + getEndpoint: (endpointId: EndpointId) => EndpointHandle, invokeKernelService: (target: KRef, message: Message) => Promise, logger?: Logger, ) { this.#kernelStore = kernelStore; this.#kernelQueue = kernelQueue; - this.#getVat = getVat; + this.#getEndpoint = getEndpoint; this.#invokeKernelService = invokeKernelService; this.#logger = logger; } @@ -115,15 +115,15 @@ export class KernelRouter { /** * Determine a message's destination route based on the target type and - * state. In the most general case, this route consists of a vatId and a + * state. In the most general case, this route consists of an endpointId and a * destination object reference. * * There are three possible outcomes: * - splat: message should be dropped (with optional error resolution), * indicated by a null return value - * - send: message should be delivered to a specific object in a specific vat + * - send: message should be delivered to a specific object at a specific endpoint * - requeue: message should be put back on the run queue for later delivery - * (for unresolved promises), indicated by absence of a target vat in the + * (for unresolved promises), indicated by absence of a target endpoint in the * return value * * @param item - The message to route. @@ -145,11 +145,11 @@ export class KernelRouter { if (this.#kernelStore.isRevoked(targetObject)) { return routeAsSplat(kser('revoked object')); } - const vatId = this.#kernelStore.getOwner(targetObject); - if (!vatId) { - return routeAsSplat(kser('no vat')); + const endpointId = this.#kernelStore.getOwner(targetObject); + if (!endpointId) { + return routeAsSplat(kser('no endpoint')); } - return { vatId, target: targetObject }; + return { endpointId, target: targetObject }; }; const routeAsRequeue = (targetObject: KRef): MessageRoute => { return { target: targetObject }; @@ -212,37 +212,42 @@ export class KernelRouter { return crankResults; } - const { vatId, target } = route; + const { endpointId, target } = route; const { message } = item; this.#logger?.log( - `@@@@ deliver ${vatId} send ${target}<-${JSON.stringify(message)}`, + `@@@@ deliver ${endpointId} send ${target}<-${JSON.stringify(message)}`, ); - if (vatId) { - const isKernelServiceMessage = vatId === 'kernel'; - const vat = isKernelServiceMessage ? null : this.#getVat(vatId); - if (vat || isKernelServiceMessage) { + if (endpointId) { + const isKernelServiceMessage = endpointId === 'kernel'; + const endpoint = isKernelServiceMessage + ? null + : this.#getEndpoint(endpointId); + if (endpoint || isKernelServiceMessage) { if (message.result) { if (typeof message.result !== 'string') { throw TypeError('message result must be a string'); } - this.#kernelStore.setPromiseDecider(message.result, vatId); + this.#kernelStore.setPromiseDecider(message.result, endpointId); this.#kernelStore.decrementRefCount( message.result, 'deliver|send|result', ); } } - if (vat) { - const vatTarget = this.#kernelStore.translateRefKtoV( - vatId, + if (endpoint) { + const endpointTarget = this.#kernelStore.translateRefKtoE( + endpointId, target, false, ); - const vatMessage = this.#kernelStore.translateMessageKtoV( - vatId, + const endpointMessage = this.#kernelStore.translateMessageKtoE( + endpointId, message, ); - crankResults = await vat.deliverMessage(vatTarget, vatMessage); + crankResults = await endpoint.deliverMessage( + endpointTarget, + endpointMessage, + ); } else if (isKernelServiceMessage) { crankResults = await this.#deliverKernelServiceMessage(target, message); } else { @@ -255,9 +260,6 @@ export class KernelRouter { } else { this.#kernelStore.enqueuePromiseMessage(target, message); } - this.#logger?.log( - `@@@@ done ${vatId} send ${target}<-${JSON.stringify(message)}`, - ); return crankResults; } @@ -277,28 +279,30 @@ export class KernelRouter { * @returns The crank outcome. */ async #deliverNotify(item: RunQueueItemNotify): Promise { - const { vatId, kpid } = item; - insistVatId(vatId); + const { endpointId, kpid } = item; + insistEndpointId(endpointId); const { context, isPromise } = parseRef(kpid); assert( context === 'kernel' && isPromise, `${kpid} is not a kernel promise`, ); - this.#logger?.log(`@@@@ deliver ${vatId} notify ${vatId} ${kpid}`); + this.#logger?.log( + `@@@@ deliver ${endpointId} notify ${endpointId} ${kpid}`, + ); const promise = this.#kernelStore.getKernelPromise(kpid); const { state, value } = promise; assert(value, `no value for promise ${kpid}`); if (state === 'unresolved') { Fail`notification on unresolved promise ${kpid}`; } - if (!this.#kernelStore.krefToEref(vatId, kpid)) { + if (!this.#kernelStore.krefToEref(endpointId, kpid)) { // no c-list entry, already done - return { didDelivery: vatId }; + return { didDelivery: endpointId }; } const targets = this.#kernelStore.getKpidsToRetire(kpid, value); if (targets.length === 0) { // no kpids to retire, already done - return { didDelivery: vatId }; + return { didDelivery: endpointId }; } const resolutions: VatOneResolution[] = []; for (const toResolve of targets) { @@ -310,20 +314,19 @@ export class KernelRouter { throw Fail`target promise ${toResolve} has no value`; } resolutions.push([ - this.#kernelStore.translateRefKtoV(vatId, toResolve, true), + this.#kernelStore.translateRefKtoE(endpointId, toResolve, true), tPromise.state === 'rejected', - this.#kernelStore.translateCapDataKtoV(vatId, tPromise.value), + this.#kernelStore.translateCapDataKtoE(endpointId, tPromise.value), ]); // decrement refcount for the promise being notified if (toResolve !== kpid) { this.#kernelStore.decrementRefCount(toResolve, 'deliver|notify|slot'); } } - const vat = this.#getVat(vatId); - const crankResults = await vat.deliverNotify(resolutions); + const endpoint = this.#getEndpoint(endpointId); + const crankResults = await endpoint.deliverNotify(resolutions); // Decrement reference count for processed 'notify' item this.#kernelStore.decrementRefCount(kpid, 'deliver|notify'); - this.#logger?.log(`@@@@ done ${vatId} notify ${vatId} ${kpid}`); return crankResults; } @@ -334,17 +337,18 @@ export class KernelRouter { * @returns The crank outcome. */ async #deliverGCAction(item: RunQueueItemGCAction): Promise { - const { type, vatId, krefs } = item; - this.#logger?.log(`@@@@ deliver ${vatId} ${type}`, krefs); - const vat = this.#getVat(vatId); - const vrefs = this.#kernelStore.krefsToExistingErefs(vatId, krefs); + const { type, endpointId, krefs } = item; + this.#logger?.log( + `@@@@ deliver ${endpointId} ${type} ${JSON.stringify(krefs)}`, + ); + const endpoint = this.#getEndpoint(endpointId); + const erefs = this.#kernelStore.krefsToExistingErefs(endpointId, krefs); const method = `deliver${(type[0] as string).toUpperCase()}${type.slice(1)}` as | 'deliverDropExports' | 'deliverRetireExports' | 'deliverRetireImports'; - const crankResults = await vat[method](vrefs); - this.#logger?.log(`@@@@ done ${vatId} ${type}`, krefs); + const crankResults = await endpoint[method](erefs); return crankResults; } @@ -357,11 +361,10 @@ export class KernelRouter { async #deliverBringOutYourDead( item: RunQueueItemBringOutYourDead, ): Promise { - const { vatId } = item; - this.#logger?.log(`@@@@ deliver ${vatId} bringOutYourDead`); - const vat = this.#getVat(vatId); - const crankResults = await vat.deliverBringOutYourDead(); - this.#logger?.log(`@@@@ done ${vatId} bringOutYourDead`); + const { endpointId } = item; + this.#logger?.log(`@@@@ deliver ${endpointId} bringOutYourDead`); + const endpoint = this.#getEndpoint(endpointId); + const crankResults = await endpoint.deliverBringOutYourDead(); return crankResults; } } diff --git a/packages/ocap-kernel/src/RemoteHandle.test.ts b/packages/ocap-kernel/src/RemoteHandle.test.ts new file mode 100644 index 0000000000..adaeaff532 --- /dev/null +++ b/packages/ocap-kernel/src/RemoteHandle.test.ts @@ -0,0 +1,429 @@ +import type { VatOneResolution } from '@agoric/swingset-liveslots'; +import type { Logger } from '@metamask/logger'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { KernelQueue } from './KernelQueue.ts'; +import { RemoteHandle } from './RemoteHandle.ts'; +import { makeKernelStore } from './store/index.ts'; +import type { KernelStore } from './store/index.ts'; +import { parseRef } from './store/utils/parse-ref.ts'; +import type { Message, RemoteComms, RRef } from './types.ts'; +import { makeMapKernelDatabase } from '../test/storage.ts'; + +let mockKernelStore: KernelStore; +let mockRemoteComms: RemoteComms; +let mockKernelQueue: KernelQueue; +const mockRemoteId = 'r0'; +const mockRemotePeerId = 'remotePeerId'; + +/* eslint-disable vitest/no-conditional-expect */ + +/** + * Fabricate a mock remote for testing purposes + * + * @param logger - A logger, if you care. + * + * @returns a new RemoteHandle suitable for use in testing. + */ +export function makeRemote(logger?: Logger): RemoteHandle { + return RemoteHandle.make({ + remoteId: mockRemoteId, + peerId: mockRemotePeerId, + kernelStore: mockKernelStore, + kernelQueue: mockKernelQueue, + remoteComms: mockRemoteComms, + logger, + }); +} + +describe('RemoteHandle', () => { + beforeEach(() => { + mockKernelStore = makeKernelStore(makeMapKernelDatabase()); + const mockRedeemLocalOcapURL = vi.fn(); + mockRedeemLocalOcapURL.mockReturnValue('ko100'); + mockRemoteComms = { + getPeerId: () => 'myPeerId', + sendRemoteMessage: vi.fn(), + issueOcapURL: vi.fn(), + redeemLocalOcapURL: mockRedeemLocalOcapURL, + }; + mockKernelQueue = { + run: vi.fn(), + enqueueMessage: vi.fn(), + enqueueSend: vi.fn(), + enqueueNotify: vi.fn(), + resolvePromises: vi.fn(), + } as unknown as KernelQueue; + }); + + it('deliverMessage calls sendRemoteMessage with correct delivery message', async () => { + const remote = makeRemote(); + const target: RRef = 'ro+1'; + const message: Message = { + methargs: { body: '["method",["arg1","arg2"]]', slots: [] }, + result: 'rp-2', + }; + const crankResult = await remote.deliverMessage(target, message); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'deliver', + params: ['message', target, message], + }), + ); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('deliverNotify calls sendRemoteMessage with correct delivery message', async () => { + const remote = makeRemote(); + const resolutions: VatOneResolution[] = [ + ['rp-3', false, { body: '"resolved value"', slots: [] }], + ]; + + const crankResult = await remote.deliverNotify(resolutions); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'deliver', + params: ['notify', resolutions], + }), + ); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('deliverDropExports calls sendRemoteMessage with correct delivery message', async () => { + const remote = makeRemote(); + const rrefs: RRef[] = ['ro+4', 'ro+5']; + + const crankResult = await remote.deliverDropExports(rrefs); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'deliver', + params: ['dropExports', rrefs], + }), + ); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('deliverRetireExports calls sendRemoteMessage with correct delivery message', async () => { + const remote = makeRemote(); + const rrefs: RRef[] = ['ro+4', 'ro+5']; + + const crankResult = await remote.deliverRetireExports(rrefs); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'deliver', + params: ['retireExports', rrefs], + }), + ); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('deliverRetireImports calls sendRemoteMessage with correct delivery message', async () => { + const remote = makeRemote(); + const rrefs: RRef[] = ['ro+4', 'ro+5']; + + const crankResult = await remote.deliverRetireImports(rrefs); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'deliver', + params: ['retireImports', rrefs], + }), + ); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('deliverBringOutYourDead does not call sendRemoteMessage', async () => { + const remote = makeRemote(); + + const crankResult = await remote.deliverBringOutYourDead(); + expect(mockRemoteComms.sendRemoteMessage).not.toHaveBeenCalled(); + expect(crankResult).toStrictEqual({ didDelivery: remote.remoteId }); + }); + + it('redeemOcapURL calls sendRemoteMessage correctly and handles expected reply (success)', async () => { + const remote = makeRemote(); + const mockOcapURL = 'as if it was a URL'; + const mockURLResolutionRRef = 'ro+6'; + const mockURLResolutionKRef = 'ko1'; + const expectedReplyKey = '1'; + + const urlPromise = remote.redeemOcapURL(mockOcapURL); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'redeemURL', + params: [mockOcapURL, expectedReplyKey], + }), + ); + const redeemURLReply = { + method: 'redeemURLReply', + params: [true, expectedReplyKey, mockURLResolutionRRef], + }; + await remote.handleRemoteMessage(JSON.stringify(redeemURLReply)); + const kref = await urlPromise; + expect(kref).toBe(mockURLResolutionKRef); + expect( + mockKernelStore.translateRefEtoK(remote.remoteId, mockURLResolutionRRef), + ).toBe(mockURLResolutionKRef); + }); + + it('redeemOcapURL calls sendRemoteMessage correctly and handles expected reply (failure)', async () => { + const remote = makeRemote(); + const mockOcapURL = 'as if it was a URL'; + const expectedReplyKey = '1'; + + const urlPromise = remote.redeemOcapURL(mockOcapURL); + expect(mockRemoteComms.sendRemoteMessage).toHaveBeenCalledWith( + mockRemotePeerId, + JSON.stringify({ + method: 'redeemURL', + params: [mockOcapURL, expectedReplyKey], + }), + ); + const redeemURLReply = { + method: 'redeemURLReply', + params: [false, expectedReplyKey], + }; + await remote.handleRemoteMessage(JSON.stringify(redeemURLReply)); + await expect(urlPromise).rejects.toThrow( + `vitest ignores this string but lint complains if it's not here`, + ); + }); + + it('handleRemoteMessage handles deliver message', async () => { + const remote = makeRemote(); + const targetRRef = 'ro+1'; + const targetKRef = 'ko1'; + const resultRRef = 'rp+2'; + const resultKRef = 'kp1'; + const message: Message = { + methargs: { body: '["method",["arg1","arg2"]]', slots: [] }, + result: resultRRef, + }; + const delivery = JSON.stringify({ + method: 'deliver', + params: ['message', targetRRef, message], + }); + const reply = await remote.handleRemoteMessage(delivery); + expect(reply).toBe(''); + expect(mockKernelQueue.enqueueSend).toHaveBeenCalledWith(targetKRef, { + methargs: message.methargs, + result: resultKRef, + }); + expect(mockKernelStore.translateRefEtoK(remote.remoteId, targetRRef)).toBe( + targetKRef, + ); + expect(mockKernelStore.translateRefEtoK(remote.remoteId, resultRRef)).toBe( + resultKRef, + ); + }); + + it('handleRemoteMessage handles deliver notify', async () => { + const remote = makeRemote(); + const promiseRRef = 'rp+3'; + const promiseKRef = 'kp1'; + const resolutions: VatOneResolution[] = [ + [promiseRRef, false, { body: '"resolved value"', slots: [] }], + ]; + const notify = JSON.stringify({ + method: 'deliver', + params: ['notify', resolutions], + }); + const reply = await remote.handleRemoteMessage(notify); + expect(reply).toBe(''); + expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith( + remote.remoteId, + [[promiseKRef, false, { body: '"resolved value"', slots: [] }]], + ); + }); + + it('handleRemoteMessage handles deliver dropExports', async () => { + const remote = makeRemote(); + + // Note that vat v1 does not exist; we're just pretending the test object + // came from there (because it had to come from *somewhere*). + const koref = mockKernelStore.initKernelObject('v1'); + const [kpref] = mockKernelStore.initKernelPromise(); + + // Pretend these refs had earlier been imported into the test remote from + // our kernel (as if they had, say, appeared in message slots) and thence were + // exported at the remote end. This way they'll be here to be dropped when + // a request to do so is "received". + const roref = mockKernelStore.translateRefKtoE( + remote.remoteId, + koref, + true, + ); + const rpref = mockKernelStore.translateRefKtoE( + remote.remoteId, + kpref, + true, + ); + + const drops = [ + mockKernelStore.invertRRef(roref), + mockKernelStore.invertRRef(rpref), + ]; + + const krefs = drops.map((rref) => { + const result = mockKernelStore.translateRefEtoK(remote.remoteId, rref); + return result; + }); + for (const kref of krefs) { + const { isPromise } = parseRef(kref); + if (isPromise) { + expect(mockKernelStore.getRefCount(kref)).toBe(1); + } else { + expect(mockKernelStore.getObjectRefCount(kref)).toStrictEqual({ + reachable: 1, + recognizable: 1, + }); + } + } + + // Now have the "other end" drop them. + const dropExports = JSON.stringify({ + method: 'deliver', + params: ['dropExports', drops], + }); + const reply = await remote.handleRemoteMessage(dropExports); + + expect(reply).toBe(''); + for (const kref of krefs) { + const { isPromise } = parseRef(kref); + if (isPromise) { + expect(mockKernelStore.getRefCount(kref)).toBe(1); + } else { + expect(mockKernelStore.getObjectRefCount(kref)).toStrictEqual({ + reachable: 0, + recognizable: 1, + }); + } + } + }); + + it('handleRemoteMessage handles deliver retireExports', async () => { + const remote = makeRemote(); + + // Note that vat v1 does not exist; we're just pretending the test object + // came from there (because it had to come from *somewhere*). + const koref = mockKernelStore.initKernelObject('v1'); + + // Pretend this ref had earlier been imported into the test remote from our + // kernel (as if it had, say, appeared in message slots) and thence wwas + // exported at the remote end. This way it'll be here to be retired when a + // request to do so is "received". + const roref = mockKernelStore.translateRefKtoE( + remote.remoteId, + koref, + true, + ); + + const toRetireRRef = mockKernelStore.invertRRef(roref); + + const kref = mockKernelStore.translateRefEtoK( + remote.remoteId, + toRetireRRef, + ); + expect(mockKernelStore.getObjectRefCount(kref)).toStrictEqual({ + reachable: 1, + recognizable: 1, + }); + + // Before we can retire, we have to drop, so pretend that happened too + mockKernelStore.clearReachableFlag(remote.remoteId, kref); + + // Now have the "other end" retire them. + const retireExports = JSON.stringify({ + method: 'deliver', + params: ['retireExports', [toRetireRRef]], + }); + const reply = await remote.handleRemoteMessage(retireExports); + + expect(reply).toBe(''); + expect(mockKernelStore.getObjectRefCount(kref)).toStrictEqual({ + reachable: 0, + recognizable: 0, + }); + }); + + it('handleRemoteMessage handles deliver retireImports', async () => { + const remote = makeRemote(); + + // An object, as if it had been imported from the other end (and thus exported here) + const roref = 'ro+1'; + const koref = mockKernelStore.translateRefEtoK(remote.remoteId, roref); + + // As if we're no longer using it (which, in fact, we weren't), which is a + // prequisite for a valid 'retireImports' delivery + mockKernelStore.decrementRefCount(koref, 'test'); + mockKernelStore.clearReachableFlag(remote.remoteId, koref); + + // Now have the "other end" retire the import. + const retireImports = JSON.stringify({ + method: 'deliver', + params: ['retireImports', [roref]], + }); + const reply = await remote.handleRemoteMessage(retireImports); + + expect(reply).toBe(''); + + // Object should have disappeared from the clists + expect(() => + mockKernelStore.translateRefKtoE(remote.remoteId, koref, false), + ).toThrow(`unmapped kref "${koref}" endpoint="${remote.remoteId}"`); + expect(mockKernelStore.erefToKref(remote.remoteId, roref)).toBeUndefined(); + }); + + it('handleRemoteMessage handles bogus deliver', async () => { + const remote = makeRemote(); + const delivery = JSON.stringify({ + method: 'deliver', + params: ['bogus'], + }); + await expect(remote.handleRemoteMessage(delivery)).rejects.toThrow( + 'unknown remote delivery method bogus', + ); + }); + + it('handleRemoteMessage handles redeemURL request', async () => { + const remote = makeRemote(); + const mockOcapURL = 'as if it was a URL'; + const mockReplyKey = 'replyKey'; + const replyKRef = 'ko100'; + const replyRRef = 'ro+1'; + const request = JSON.stringify({ + method: 'redeemURL', + params: [mockOcapURL, mockReplyKey], + }); + mockKernelStore.kv.set(`e.nextObjectId.r0`, `1`); // mock effects of stuff that was never called + const reply = await remote.handleRemoteMessage(request); + expect(mockRemoteComms.redeemLocalOcapURL).toHaveBeenCalledWith( + mockOcapURL, + ); + expect(reply).toBe( + JSON.stringify({ + method: 'redeemURLReply', + params: [true, mockReplyKey, replyRRef], + }), + ); + expect( + mockKernelStore.translateRefKtoE(remote.remoteId, replyKRef, false), + ).toBe(replyRRef); + }); + + it('handleRemoteMessage rejects bogus message type', async () => { + const remote = makeRemote(); + const request = JSON.stringify({ + method: 'bogus', + params: [], + }); + await expect(remote.handleRemoteMessage(request)).rejects.toThrow( + 'unknown remote message type bogus', + ); + }); +}); diff --git a/packages/ocap-kernel/src/RemoteHandle.ts b/packages/ocap-kernel/src/RemoteHandle.ts new file mode 100644 index 0000000000..acb7df1643 --- /dev/null +++ b/packages/ocap-kernel/src/RemoteHandle.ts @@ -0,0 +1,454 @@ +import type { VatOneResolution } from '@agoric/swingset-liveslots'; +import type { CapData } from '@endo/marshal'; +import { makePromiseKit } from '@endo/promise-kit'; +import { Logger } from '@metamask/logger'; + +import { + performDropImports, + performRetireImports, + performExportCleanup, +} from './gc-handlers.ts'; +import type { KernelQueue } from './KernelQueue.ts'; +import type { KernelStore } from './store/index.ts'; +import type { + RemoteId, + ERef, + EndpointHandle, + Message, + CrankResults, + RemoteComms, +} from './types.ts'; + +type RemoteHandleConstructorProps = { + remoteId: RemoteId; + peerId: string; + kernelStore: KernelStore; + kernelQueue: KernelQueue; + remoteComms: RemoteComms; + logger?: Logger | undefined; +}; + +type MessageDelivery = ['message', string, Message]; +type NotifyDelivery = ['notify', VatOneResolution[]]; +type DropExportsDelivery = ['dropExports', string[]]; +type RetireExportsDelivery = ['retireExports', string[]]; +type RetireImportsDelivery = ['retireImports', string[]]; + +type DeliveryParams = + | MessageDelivery + | NotifyDelivery + | DropExportsDelivery + | RetireExportsDelivery + | RetireImportsDelivery; + +type Delivery = { + method: 'deliver'; + params: DeliveryParams; +}; + +type RedeemURLRequest = { + method: 'redeemURL'; + params: [string, string]; +}; + +type RedeemURLReply = { + method: 'redeemURLReply'; + params: [boolean, string, string]; +}; + +type RemoteCommand = Delivery | RedeemURLRequest | RedeemURLReply; + +export class RemoteHandle implements EndpointHandle { + /** The ID of the remote connection this is the RemoteHandle for. */ + readonly remoteId: RemoteId; + + /** The peer ID of the remote kernel this is connected to. */ + readonly #peerId: string; + + /** Logger for outputting messages (such as errors) to the console */ + // eslint-disable-next-line no-unused-private-class-members + readonly #logger: Logger; + + /** Storage holding the kernel's persistent state. */ + readonly #kernelStore: KernelStore; + + /** The kernel's queue */ + readonly #kernelQueue: KernelQueue; + + /** Connectivity to the network. */ + readonly #remoteComms: RemoteComms; + + /** Pending URL redemption requests that have not yet been responded to. */ + readonly #pendingRedemptions: Map< + string, + [(ref: string) => void, (problem: string) => void] + > = new Map(); + + /** Generation counter for keys to match URL redemption replies to requests. */ + #redemptionCounter: number = 1; + + /** Crank result object to reuse (since it's always the same). */ + readonly #myCrankResult: CrankResults; + + /** + * Construct a new RemoteHandle instance. + * + * @param params - Named constructor parameters. + * @param params.remoteId - Our remote ID. + * @param params.peerId - The libp2p peer ID for the remote end. + * @param params.kernelStore - The kernel's persistent state store. + * @param params.kernelQueue - The kernel's queue. + * @param params.remoteComms - Remote comms object to access the network. + * @param params.logger - Optional logger for error and diagnostic output. + */ + // eslint-disable-next-line no-restricted-syntax + private constructor({ + remoteId, + peerId, + kernelStore, + kernelQueue, + remoteComms, + logger, + }: RemoteHandleConstructorProps) { + this.remoteId = remoteId; + this.#peerId = peerId; + this.#logger = logger ?? new Logger(`[remote ${remoteId}]`); + this.#kernelStore = kernelStore; + this.#kernelQueue = kernelQueue; + this.#remoteComms = remoteComms; + this.#myCrankResult = { didDelivery: remoteId }; + kernelStore.initEndpoint(remoteId); + } + + /** + * Construct a new RemoteHandle instance. + * + * @param params - Named constructor parameters. + * @param params.remoteId - Our remote ID. + * @param params.peerId - The libp2p peer ID for the remote end. + * @param params.kernelStore - The kernel's persistent state store. + * @param params.kernelQueue - The kernel's queue. + * @param params.remoteComms - Remote comms object to access the network. + * @param params.logger - Optional logger for error and diagnostic output. + * + * @returns a promise for the new RemoteHandle instance. + */ + static make(params: RemoteHandleConstructorProps): RemoteHandle { + const remote = new RemoteHandle(params); + return remote; + } + + /** + * Transmit a message to the remote end of the connection. + * + * @param message - The message to send. + */ + async #sendRemoteCommand(message: RemoteCommand): Promise { + await this.#remoteComms.sendRemoteMessage( + this.#peerId, + JSON.stringify(message), + ); + } + + /** + * Send a 'message' delivery to the remote. + * + * @param target - The ref of the object to which the message is addressed. + * @param message - The message to deliver. + * @returns the crank results. + */ + async deliverMessage(target: ERef, message: Message): Promise { + await this.#sendRemoteCommand({ + method: 'deliver', + params: ['message', target, message], + }); + return this.#myCrankResult; + } + + /** + * Send a 'notify' delivery to the remote. + * + * @param resolutions - One or more promise resolutions to deliver. + * @returns the crank results. + */ + async deliverNotify(resolutions: VatOneResolution[]): Promise { + await this.#sendRemoteCommand({ + method: 'deliver', + params: ['notify', resolutions], + }); + return this.#myCrankResult; + } + + /** + * Send a 'dropExports' delivery to the remote. + * + * @param erefs - The refs of the exports to be dropped. + * @returns the crank results. + */ + async deliverDropExports(erefs: ERef[]): Promise { + await this.#sendRemoteCommand({ + method: 'deliver', + params: ['dropExports', erefs], + }); + return this.#myCrankResult; + } + + /** + * Send a 'retireExports' delivery to the remote. + * + * @param erefs - The refs of the exports to be retired. + * @returns the crank results. + */ + async deliverRetireExports(erefs: ERef[]): Promise { + await this.#sendRemoteCommand({ + method: 'deliver', + params: ['retireExports', erefs], + }); + return this.#myCrankResult; + } + + /** + * Send a 'retireImports' delivery to the remote. + * + * @param erefs - The refs of the imports to be retired. + * @returns the crank results. + */ + async deliverRetireImports(erefs: ERef[]): Promise { + await this.#sendRemoteCommand({ + method: 'deliver', + params: ['retireImports', erefs], + }); + return this.#myCrankResult; + } + + /** + * Make a 'bringOutYourDead' delivery to the remote. + * + * Currently this does not actually do anything but is included to satisfy the + * EndpointHandle interface. + * + * @returns the crank results. + */ + async deliverBringOutYourDead(): Promise { + // XXX Currently a no-op, but probably some further DGC action is warranted here + return this.#myCrankResult; + } + + // Warning: The handling of the GC deliveries ('dropExports', 'retireExports', + // and 'dropImports') is very confusing. + // + // For example, in the context of this RemoteHandle, 'dropExports' means the + // RemoteHandle at the other end of the network was delivered a 'dropExports' + // by *its* kernel, telling it that references which that RemoteHandle had + // been exporting to its kernel are no longer referenced by that kernel. But + // exports from the remote end to its kernel are imports from the local kernel + // into this RemoteHandle (which is to say, this end had to import them from + // the local kernel here in order to have them so they could be exported at + // the other end). This in turn means that receiving a 'dropExports' message + // over the network tells this RemoteHandle to stop importing the indicated + // references. A vat in these circumstances would use a 'dropImports' syscall + // to accomplish this, and we use the same code that underpins the + // 'dropImports' syscall to do that job here. But it's definitely confusing + // that we use 'dropImports' code to implement 'dropExports'. Analogous + // reasoning applies to the other GC deliveries: + // + // DELIVERY | "SYSCALL" + // --------------+-------------- + // dropExports | dropImports + // retireExports | retireImports + // retireImports | retireExports + + /** + * Handle a 'dropExports' delivery from the remote end. + * + * @param erefs - The refs of the exports to be dropped. + */ + #dropExports(erefs: ERef[]): void { + const krefs = erefs.map((ref) => + this.#kernelStore.translateRefEtoK(this.remoteId, ref), + ); + performDropImports(krefs, this.remoteId, this.#kernelStore); + } + + /** + * Handle a 'retireExports' delivery from the remote end. + * + * @param erefs - The refs of the exports to be retired. + */ + #retireExports(erefs: ERef[]): void { + const krefs = erefs.map((ref) => + this.#kernelStore.translateRefEtoK(this.remoteId, ref), + ); + performRetireImports(krefs, this.remoteId, this.#kernelStore); + } + + /** + * Handle a 'retireImports' delivery from the remote end. + * + * @param erefs - The refs of the imports to be retired. + */ + #retireImports(erefs: ERef[]): void { + const krefs = erefs.map((ref) => + this.#kernelStore.translateRefEtoK(this.remoteId, ref), + ); + performExportCleanup(krefs, true, this.remoteId, this.#kernelStore); + } + + /** + * Handle a delivery from the remote end. + * + * @param params - the delivery params, which vary based on the kind of delivery. + */ + #handleRemoteDeliver(params: DeliveryParams): void { + const [method] = params; + switch (method) { + case 'message': { + const [, target, message] = params; + this.#kernelQueue.enqueueSend( + this.#kernelStore.translateRefEtoK(this.remoteId, target), + this.#kernelStore.translateMessageEtoK(this.remoteId, message), + ); + break; + } + case 'notify': { + const [, resolutions] = params; + const kResolutions: VatOneResolution[] = resolutions.map( + (resolution) => { + const [rpid, rejected, data] = resolution; + return [ + this.#kernelStore.translateRefEtoK(this.remoteId, rpid), + rejected, + this.#kernelStore.translateCapDataEtoK( + this.remoteId, + data as CapData, + ), + ]; + }, + ); + this.#kernelQueue.resolvePromises(this.remoteId, kResolutions); + break; + } + case 'dropExports': { + const [, erefs] = params; + this.#dropExports(erefs); + break; + } + case 'retireExports': { + const [, erefs] = params; + this.#retireExports(erefs); + break; + } + case 'retireImports': { + const [, erefs] = params; + this.#retireImports(erefs); + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw Error(`unknown remote delivery method ${method}`); + } + } + + /** + * Handle an ocap URL redemption request from the remote end. + * + * @param url - The ocap URL attempting to be redeemed. + * @param replyKey - A sender-provided tag to send with the reply. + * + * @returns a string containing the 'redeemURLReply' message to send back to the requester. + */ + async #handleRedeemURLRequest( + url: string, + replyKey: string, + ): Promise { + assert.typeof(replyKey, 'string'); + let kref: string; + try { + kref = await this.#remoteComms.redeemLocalOcapURL(url); + } catch (error) { + return JSON.stringify({ + method: 'redeemURLReply', + params: [false, replyKey, `${(error as Error).message}`], + }); + } + const eref = this.#kernelStore.translateRefKtoE(this.remoteId, kref, true); + return JSON.stringify({ + method: 'redeemURLReply', + params: [true, replyKey, eref], + }); + } + + /** + * Handle an ocap URL redemption reply from the remote end. + * + * @param success - true if the result is a URL, false if the result is an error. + * @param replyKey - that tag that was sent in the request being replied to. + * @param result - if success, an object ref; if not, an error message string. + */ + async #handleRedeemURLReply( + success: boolean, + replyKey: string, + result: string, + ): Promise { + const handlers = this.#pendingRedemptions.get(replyKey); + if (!handlers) { + throw Error(`unknown URL redemption reply key ${replyKey}`); + } + this.#pendingRedemptions.delete(replyKey); + const [resolve, reject] = handlers; + if (success) { + resolve(this.#kernelStore.translateRefEtoK(this.remoteId, result)); + } else { + reject(result); + } + } + + /** + * Handle a communication received from the remote end. + * + * @param message - The message that was received. + * + * @returns a string containing a message to send back to the original message + * sender as a response. An empty string means no such message is to be sent. + */ + async handleRemoteMessage(message: string): Promise { + const remoteCommand: RemoteCommand = JSON.parse(message); + const { method, params } = remoteCommand; + let result = ''; + switch (method) { + case 'deliver': + this.#handleRemoteDeliver(params); + break; + case 'redeemURL': + result = await this.#handleRedeemURLRequest(...params); + break; + case 'redeemURLReply': + await this.#handleRedeemURLReply(...params); + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw Error(`unknown remote message type ${method}`); + } + return result; + } + + /** + * Obtain a reference to an object designated by an ocap URL. + * + * @param url - The ocap URL to be redeemed. + * + * @returns a promise for the kref of the object designated by `url`. + */ + async redeemOcapURL(url: string): Promise { + const replyKey = `${this.#redemptionCounter}`; + this.#redemptionCounter += 1; + const { promise, resolve, reject } = makePromiseKit(); + // XXX TODO: Probably these should have timeouts + this.#pendingRedemptions.set(replyKey, [resolve, reject]); + await this.#sendRemoteCommand({ + method: 'redeemURL', + params: [url, replyKey], + }); + return promise; + } +} diff --git a/packages/ocap-kernel/src/VatHandle.ts b/packages/ocap-kernel/src/VatHandle.ts index 2cc9920de7..de308a642e 100644 --- a/packages/ocap-kernel/src/VatHandle.ts +++ b/packages/ocap-kernel/src/VatHandle.ts @@ -27,6 +27,7 @@ import type { VRef, CrankResults, VatDeliveryResult, + EndpointHandle, } from './types.ts'; import { VatSyscall } from './VatSyscall.ts'; @@ -43,7 +44,7 @@ type VatConstructorProps = { logger?: Logger | undefined; }; -export class VatHandle { +export class VatHandle implements EndpointHandle { /** The ID of the vat this is the VatHandle for */ readonly vatId: VatId; diff --git a/packages/ocap-kernel/src/VatSupervisor.ts b/packages/ocap-kernel/src/VatSupervisor.ts index 7e40255f93..65629efa23 100644 --- a/packages/ocap-kernel/src/VatSupervisor.ts +++ b/packages/ocap-kernel/src/VatSupervisor.ts @@ -65,7 +65,7 @@ export class VatSupervisor { readonly #rpcClient: SupervisorRpcClient; /** RPC service for handling requests from the kernel */ - readonly #rpcService: RpcService; + readonly #rpcServer: RpcService; /** Flag that the user code has been loaded */ #loaded: boolean = false; @@ -131,7 +131,7 @@ export class VatSupervisor { this.#logger.subLogger({ tags: ['rpc-client'] }), ); - this.#rpcService = new RpcService(vatHandlers, { + this.#rpcServer = new RpcService(vatHandlers, { initVat: this.#initVat.bind(this), handleDelivery: this.#deliver.bind(this), }); @@ -166,8 +166,8 @@ export class VatSupervisor { this.#rpcClient.handleResponse(message.id as string, message); } else if (isJsonRpcRequest(message)) { try { - this.#rpcService.assertHasMethod(message.method); - const result = await this.#rpcService.execute( + this.#rpcServer.assertHasMethod(message.method); + const result = await this.#rpcServer.execute( message.method, message.params, ); diff --git a/packages/ocap-kernel/src/VatSyscall.ts b/packages/ocap-kernel/src/VatSyscall.ts index e65390fe14..e4505c1345 100644 --- a/packages/ocap-kernel/src/VatSyscall.ts +++ b/packages/ocap-kernel/src/VatSyscall.ts @@ -6,10 +6,14 @@ import type { } from '@agoric/swingset-liveslots'; import { Logger } from '@metamask/logger'; +import { + performDropImports, + performRetireImports, + performExportCleanup, +} from './gc-handlers.ts'; import type { KernelQueue } from './KernelQueue.ts'; import { makeError } from './services/kernel-marshal.ts'; import type { KernelStore } from './store/index.ts'; -import { parseRef } from './store/utils/parse-ref.ts'; import { coerceMessage } from './types.ts'; import type { Message, VatId, KRef } from './types.ts'; @@ -105,16 +109,7 @@ export class VatSyscall { * @param krefs - The KRefs of the imports to be dropped. */ #handleSyscallDropImports(krefs: KRef[]): void { - for (const kref of krefs) { - const { direction, isPromise } = parseRef(kref); - // We validate it's an import - meaning this vat received this object from somewhere else - if (direction === 'export' || isPromise) { - throw Error( - `vat ${this.vatId} issued invalid syscall dropImports for ${kref}`, - ); - } - this.#kernelStore.clearReachableFlag(this.vatId, kref); - } + performDropImports(krefs, this.vatId, this.#kernelStore); } /** @@ -123,47 +118,23 @@ export class VatSyscall { * @param krefs - The KRefs of the imports to be retired. */ #handleSyscallRetireImports(krefs: KRef[]): void { - for (const kref of krefs) { - const { direction, isPromise } = parseRef(kref); - // We validate it's an import - meaning this vat received this object from somewhere else - if (direction === 'export' || isPromise) { - throw Error( - `vat ${this.vatId} issued invalid syscall retireImports for ${kref}`, - ); - } - if (this.#kernelStore.getReachableFlag(this.vatId, kref)) { - throw Error(`syscall.retireImports but ${kref} is still reachable`); - } - // deleting the clist entry will decrement the recognizable count, but - // not the reachable count (because it was unreachable, as we asserted) - this.#kernelStore.forgetKref(this.vatId, kref); - } + performRetireImports(krefs, this.vatId, this.#kernelStore); } /** * Handle retiring or abandoning exports syscall from the vat. * * @param krefs - The KRefs of the exports to be retired/abandoned. - * @param checkReachable - If true, verify the object is not reachable (retire). If false, ignore reachability (abandon). + * @param checkReachable - If true, verify the object is not reachable + * (retire). If false, ignore reachability (abandon). */ #handleSyscallExportCleanup(krefs: KRef[], checkReachable: boolean): void { + performExportCleanup(krefs, checkReachable, this.vatId, this.#kernelStore); + + // XXX This log output is only here for the benefit of a couple of the + // tests. Arguably, those tests should be revised not to need it, but for now... const action = checkReachable ? 'retire' : 'abandon'; for (const kref of krefs) { - const { direction, isPromise } = parseRef(kref); - // We validate it's an export - meaning this vat created/owns this object - if (direction === 'import' || isPromise) { - throw Error( - `vat ${this.vatId} issued invalid syscall ${action}Exports for ${kref}`, - ); - } - if (checkReachable) { - if (this.#kernelStore.getReachableFlag(this.vatId, kref)) { - throw Error( - `syscall.${action}Exports but ${kref} is still reachable`, - ); - } - } - this.#kernelStore.forgetKref(this.vatId, kref); this.#logger?.debug(`${action}Exports: deleted object ${kref}`); } } diff --git a/packages/ocap-kernel/src/gc-handlers.ts b/packages/ocap-kernel/src/gc-handlers.ts new file mode 100644 index 0000000000..974eef27d4 --- /dev/null +++ b/packages/ocap-kernel/src/gc-handlers.ts @@ -0,0 +1,88 @@ +import type { KernelStore } from './store/index.ts'; +import { parseRef } from './store/utils/parse-ref.ts'; +import type { KRef, EndpointId } from './types.ts'; + +/** + * Do the work of a 'dropImports' syscall. + * + * @param krefs - The KRefs of the imports to be dropped. + * @param endpointId - The endpoint doing the dropping. + * @param kernelStore - The kernel store. + */ +export function performDropImports( + krefs: KRef[], + endpointId: EndpointId, + kernelStore: KernelStore, +): void { + for (const kref of krefs) { + /* + const { isPromise } = parseRef(kref); + if (isPromise) { + throw Error( + `endpoint ${endpointId} issued invalid dropImports for ${kref}`, + ); + } + */ + kernelStore.clearReachableFlag(endpointId, kref); + } +} + +/** + * Do the work of a 'retireImports' syscall. + * + * @param krefs - The KRefs of the imports to be retired. + * @param endpointId - The endpoint doing the retiring. + * @param kernelStore - The kernel store. + */ +export function performRetireImports( + krefs: KRef[], + endpointId: EndpointId, + kernelStore: KernelStore, +): void { + for (const kref of krefs) { + const { isPromise } = parseRef(kref); + if (isPromise) { + throw Error( + `endpoint ${endpointId} issued invalid retireImports for ${kref}`, + ); + } + if (kernelStore.getReachableFlag(endpointId, kref)) { + throw Error(`retireImports but ${kref} is still reachable`); + } + // deleting the clist entry will decrement the recognizable count, but + // not the reachable count (because it was unreachable, as we asserted) + kernelStore.forgetKref(endpointId, kref); + } +} + +/** + * Do the work of a 'dropExports' or 'abandonExports' syscall. + * + * @param krefs - The KRefs of the exports to be dropped or abandoned. + * @param checkReachable - If true, verify the object is not reachable + * (retire). If false, ignore reachability (abandon). + * @param endpointId - The endpoint doing the operation. + * @param kernelStore - The kernel store. + */ +export function performExportCleanup( + krefs: KRef[], + checkReachable: boolean, + endpointId: EndpointId, + kernelStore: KernelStore, +): void { + const action = checkReachable ? 'retire' : 'abandon'; + for (const kref of krefs) { + const { isPromise } = parseRef(kref); + if (isPromise) { + throw Error( + `endpoint ${endpointId} issued invalid ${action}Exports for ${kref}`, + ); + } + if (checkReachable) { + if (kernelStore.getReachableFlag(endpointId, kref)) { + throw Error(`${action}Exports but ${kref} is still reachable`); + } + } + kernelStore.forgetKref(endpointId, kref); + } +} diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index 18ddbade3f..0d056bbf2a 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -14,6 +14,7 @@ describe('index', () => { 'VatHandle', 'VatIdStruct', 'VatSupervisor', + 'initNetwork', 'isVatConfig', 'isVatId', 'krefOf', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index d28b220ad9..713ea9172d 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -1,14 +1,18 @@ export { Kernel } from './Kernel.ts'; export { VatHandle } from './VatHandle.ts'; export { VatSupervisor } from './VatSupervisor.ts'; +export { initNetwork } from './network.ts'; export type { ClusterConfig, KRef, Message, VatId, - VatWorkerService, + RemoteMessageHandler, + PlatformServices, VatConfig, KernelStatus, + SendRemoteMessage, + RemoteComms, Subcluster, SubclusterId, } from './types.ts'; diff --git a/packages/ocap-kernel/src/network.ts b/packages/ocap-kernel/src/network.ts new file mode 100644 index 0000000000..080ec04e21 --- /dev/null +++ b/packages/ocap-kernel/src/network.ts @@ -0,0 +1,232 @@ +import { noise } from '@chainsafe/libp2p-noise'; +import { yamux } from '@chainsafe/libp2p-yamux'; +import { bootstrap } from '@libp2p/bootstrap'; +import { circuitRelayTransport } from '@libp2p/circuit-relay-v2'; +import { generateKeyPairFromSeed } from '@libp2p/crypto/keys'; +import { identify } from '@libp2p/identify'; +import type { PrivateKey } from '@libp2p/interface'; +import { webRTC } from '@libp2p/webrtc'; +import { webSockets } from '@libp2p/websockets'; +import { webTransport } from '@libp2p/webtransport'; +import { fromHex } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import { multiaddr } from '@multiformats/multiaddr'; +import type { ByteStream } from 'it-byte-stream'; +import { byteStream } from 'it-byte-stream'; +import { createLibp2p } from 'libp2p'; +import { toString as bufToString, fromString } from 'uint8arrays'; + +import type { SendRemoteMessage } from './types.ts'; + +type Channel = { + msgStream: ByteStream; + peerId: string; +}; + +export type RemoteMessageHandler = ( + from: string, + message: string, +) => Promise; + +/** + * Generate the information needed for a network identity. + * + * @param seedString - Hex string containing the key seed. + * + * @returns the private key generated from the seed. + */ +async function generateKeyInfo(seedString: string): Promise { + const keyPair = await generateKeyPairFromSeed('Ed25519', fromHex(seedString)); + return keyPair; +} + +/** + * Initialize the remote comm system with information that must be provided by the kernel. + * + * @param keySeed - Seed value for key generation, in the form of a hex-encoded string. + * @param knownRelays - PeerIds of known message relays. + * @param remoteMessageHandler - Handler to be called when messages are received from elsewhere. + * + * @returns a promise for a function that can be used to send network communications. + */ +export async function initNetwork( + keySeed: string, + knownRelays: string[], + remoteMessageHandler: RemoteMessageHandler, +): Promise { + const privateKey = await generateKeyInfo(keySeed); + const activeChannels = new Map(); // peerID -> channel info + const logger = new Logger(); + + const libp2p = await createLibp2p({ + privateKey, + addresses: { + listen: ['/webrtc', '/p2p-circuit'], + appendAnnounce: ['/webrtc'], + }, + transports: [ + webSockets(), + webTransport(), + webRTC(), + circuitRelayTransport(), + ], + connectionEncrypters: [noise()], + streamMuxers: [yamux()], + connectionGater: { + // Allow private addresses for local testing + denyDialMultiaddr: async () => false, + }, + peerDiscovery: [ + bootstrap({ + list: knownRelays, + }), + ], + services: { + identify: identify(), + }, + }); + + /** + * Output a line of text. + * + * @param text - The text to output. + */ + function outputLine(text: string): void { + logger.log(text); + } + + /** + * Output information about an error that happened. + * + * @param peerId - The network node the error was associated with. + * @param task - What we were trying to do at the time. + * @param problem - The error itself. + */ + function outputError(peerId: string, task: string, problem: unknown): void { + if (problem) { + const realProblem: Error = problem as Error; // to make eslint stfu + outputLine(`${peerId}:: error ${task}: ${realProblem}`); + } else { + outputLine(`${peerId}:: error ${task}`); + } + } + + /** + * Act upon a message received from another network node. + * + * @param from - The network node received from. + * @param message - The message that was received. + */ + async function receiveMsg(from: string, message: string): Promise { + outputLine(`${from}:: recv ${message}`); + await remoteMessageHandler(from, message); + } + + /** + * Transmit a message to some other network node. + * + * @param targetPeerId - The peerId of the intended message destination. + * @param message - The message itself. + */ + async function sendRemoteMessage( + targetPeerId: string, + message: string, + ): Promise { + let channel = activeChannels.get(targetPeerId); + if (!channel) { + try { + channel = await openChannel(targetPeerId); + } catch (problem) { + outputError(targetPeerId, 'opening connection', problem); + } + if (!channel) { + return; + } + readChannel(channel).catch((problem) => { + outputError(targetPeerId, 'reading channel', problem); + }); + } + try { + outputLine(`${targetPeerId}:: send ${message}`); + await channel.msgStream.write(fromString(message)); + } catch (problem) { + outputError(targetPeerId, 'sending message', problem); + } + } + + const SCTP_USER_INITIATED_ABORT = 12; // see RFC 4960 + + /** + * Start reading (and processing) messages arriving on a channel. + * + * @param channel - The Channel to start reading from. + */ + async function readChannel(channel: Channel): Promise { + for (;;) { + let readBuf; + try { + readBuf = await channel.msgStream.read(); + } catch (problem) { + const rtcProblem = problem as RTCError; + if ( + rtcProblem.errorDetail === 'sctp-failure' && + rtcProblem?.sctpCauseCode === SCTP_USER_INITIATED_ABORT + ) { + outputLine(`${channel.peerId}:: remote disconnected`); + } else { + outputError(channel.peerId, 'reading message', problem); + } + outputLine(`closed channel to ${channel.peerId}`); + activeChannels.delete(channel.peerId); + throw problem; + } + if (readBuf) { + await receiveMsg(channel.peerId, bufToString(readBuf.subarray())); + } + } + } + + /** + * Open a channel to the node with the given target peerId. + * + * @param peerId - The network node to connect to. + * + * @returns a Channel to `peerId`. + */ + async function openChannel(peerId: string): Promise { + outputLine(`connecting to ${peerId}`); + const signal = AbortSignal.timeout(5000000); + const addressString = `${knownRelays[0]}/p2p-circuit/webrtc/p2p/${peerId}`; + const connectToAddr = multiaddr(addressString); + + let stream; + try { + stream = await libp2p.dialProtocol(connectToAddr, 'whatever', { signal }); + } catch (problem) { + if (signal.aborted) { + outputError(peerId, `timed out opening channel`, problem); + } else { + outputError(peerId, `opening channel`, problem); + } + throw problem; + } + const msgStream = byteStream(stream); + const channel: Channel = { msgStream, peerId }; + activeChannels.set(peerId, channel); + outputLine(`opened channel to ${peerId}`); + return channel; + } + + await libp2p.handle('whatever', ({ connection, stream }) => { + const msgStream = byteStream(stream); + const remotePeerId = connection.remotePeer.toString(); + outputLine(`inbound connection from peerId:${remotePeerId}`); + const channel: Channel = { msgStream, peerId: remotePeerId }; + activeChannels.set(remotePeerId, channel); + readChannel(channel).catch(() => { + /* Nothing to do here. */ + }); + }); + + return sendRemoteMessage; +} diff --git a/packages/ocap-kernel/src/remote-comms.test.ts b/packages/ocap-kernel/src/remote-comms.test.ts new file mode 100644 index 0000000000..77588c9afe --- /dev/null +++ b/packages/ocap-kernel/src/remote-comms.test.ts @@ -0,0 +1,153 @@ +import { generateKeyPairFromSeed } from '@libp2p/crypto/keys'; +import { peerIdFromPrivateKey } from '@libp2p/peer-id'; +import { fromHex } from '@metamask/kernel-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + initRemoteComms, + parseOcapURL, + getKnownRelays, +} from './remote-comms.ts'; +import { makeKernelStore } from './store/index.ts'; +import type { KernelStore } from './store/index.ts'; +import type { PlatformServices, RemoteMessageHandler } from './types.ts'; +import { makeMapKernelDatabase } from '../test/storage.ts'; + +describe('remote-comms', () => { + let mockKernelStore: KernelStore; + let mockPlatformServices: PlatformServices; + let mockRemoteMessageHandler: RemoteMessageHandler; + + beforeEach(() => { + mockKernelStore = makeKernelStore(makeMapKernelDatabase()); + mockPlatformServices = { + launch: vi.fn(), + terminate: vi.fn(), + terminateAll: vi.fn(), + sendRemoteMessage: vi.fn(), + initializeRemoteComms: vi.fn(), + } as unknown as PlatformServices; + mockRemoteMessageHandler = vi.fn() as unknown as RemoteMessageHandler; + let counter = 1; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation((arr) => { + arr[0] = counter; + counter += 1; + return arr; + }); + }); + + describe('initRemoteComms', () => { + it('creates a working remote comms object with expected state', async () => { + const remoteComms = await initRemoteComms( + mockKernelStore, + mockPlatformServices, + mockRemoteMessageHandler, + ); + expect(remoteComms).toHaveProperty('getPeerId'); + expect(remoteComms).toHaveProperty('issueOcapURL'); + expect(remoteComms).toHaveProperty('redeemLocalOcapURL'); + expect(remoteComms).toHaveProperty('sendRemoteMessage'); + + const keySeed = mockKernelStore.kv.get('keySeed'); + expect(keySeed).toBe( + '0100000000000000000000000000000000000000000000000000000000000000', + ); + + const ocapURLKey = mockKernelStore.kv.get('ocapURLKey'); + expect(ocapURLKey).toBe( + '0200000000000000000000000000000000000000000000000000000000000000', + ); + + const peerId = mockKernelStore.kv.get('peerId'); + const keyPair = await generateKeyPairFromSeed( + 'Ed25519', + fromHex(keySeed), + ); + expect(peerId).toBe(peerIdFromPrivateKey(keyPair).toString()); + + expect(remoteComms.getPeerId()).toBe(peerId); + + const ocapURL = await remoteComms.issueOcapURL('zot'); + const { oid } = parseOcapURL(ocapURL); + const knownRelays = getKnownRelays(mockKernelStore.kv); + expect(Array.isArray(knownRelays)).toBe(true); + expect(knownRelays.length).toBeGreaterThan(0); + const referenceURL = `ocap:${oid}@${peerId},${knownRelays.join(',')}`; + expect(ocapURL).toBe(referenceURL); + + const kref = await remoteComms.redeemLocalOcapURL(ocapURL); + expect(kref).toBe('zot'); + + await remoteComms.sendRemoteMessage('elsewhere', 'your message here'); + expect(mockPlatformServices.sendRemoteMessage).toHaveBeenCalledWith( + 'elsewhere', + 'your message here', + ); + }); + + it('honors pre-existing comms initialization parameters when present', async () => { + const mockPeerId = 'mockPeerId'; + const mockKeySeed = 'abcdef'; + const mockOcapURLKey = 'mockOcapURLKey'; + mockKernelStore.kv.set('peerId', mockPeerId); + mockKernelStore.kv.set('keySeed', mockKeySeed); + mockKernelStore.kv.set('ocapURLKey', mockOcapURLKey); + const remoteComms = await initRemoteComms( + mockKernelStore, + mockPlatformServices, + mockRemoteMessageHandler, + ); + expect(remoteComms).toHaveProperty('getPeerId'); + expect(remoteComms).toHaveProperty('issueOcapURL'); + expect(remoteComms).toHaveProperty('redeemLocalOcapURL'); + expect(remoteComms).toHaveProperty('sendRemoteMessage'); + expect(mockKernelStore.kv.get('peerId')).toBe(mockPeerId); + expect(remoteComms.getPeerId()).toBe(mockPeerId); + expect(mockKernelStore.kv.get('keySeed')).toBe(mockKeySeed); + expect(mockKernelStore.kv.get('ocapURLKey')).toBe(mockOcapURLKey); + }); + }); + + describe('parseOcapURL', () => { + it('parses ocap URL into constituent parts', () => { + let sampleOcapURL = 'ocap:oid@peerid'; + const partsNoHints = parseOcapURL(sampleOcapURL); + expect(partsNoHints).toStrictEqual({ + oid: 'oid', + host: 'peerid', + hints: [], + }); + sampleOcapURL += ',hint1'; + const partsOneHints = parseOcapURL(sampleOcapURL); + expect(partsOneHints).toStrictEqual({ + oid: 'oid', + host: 'peerid', + hints: ['hint1'], + }); + sampleOcapURL += ',hint2'; + const partsMultiHints = parseOcapURL(sampleOcapURL); + expect(partsMultiHints).toStrictEqual({ + oid: 'oid', + host: 'peerid', + hints: ['hint1', 'hint2'], + }); + }); + + it('rejects unparseable URL', () => { + expect(() => parseOcapURL('utter nonsense')).toThrow('unparseable URL'); + }); + + it('rejects bad ocap URL scheme', () => { + expect(() => parseOcapURL('yuck:oid@peerid')).toThrow('not an ocap URL'); + }); + + it('rejects badly formatted ocap URL', () => { + expect(() => parseOcapURL('ocap:oid')).toThrow('bad ocap URL'); + expect(() => parseOcapURL('ocap:oid@peerid@another')).toThrow( + 'bad ocap URL', + ); + expect(() => parseOcapURL('ocap:oid@,peerless')).toThrow('bad ocap URL'); + }); + }); +}); diff --git a/packages/ocap-kernel/src/remote-comms.ts b/packages/ocap-kernel/src/remote-comms.ts new file mode 100644 index 0000000000..ba71624b8c --- /dev/null +++ b/packages/ocap-kernel/src/remote-comms.ts @@ -0,0 +1,242 @@ +import { AES_GCM } from '@libp2p/crypto/ciphers'; +import { generateKeyPairFromSeed } from '@libp2p/crypto/keys'; +import { peerIdFromPrivateKey } from '@libp2p/peer-id'; +import type { KVStore } from '@metamask/kernel-store'; +import { toHex, fromHex } from '@metamask/kernel-utils'; +import type { Logger } from '@metamask/logger'; +import { base58btc } from 'multiformats/bases/base58'; + +import type { KernelStore } from './store/index.ts'; +import type { + PlatformServices, + RemoteComms, + RemoteMessageHandler, +} from './types.ts'; + +export type OcapURLParts = { + oid: string; + host: string; + hints: string[]; +}; + +/** + * Break down an ocap URL string into its constituent parts. + * + * @param ocapURL - The ocap URL to be parsed. + * + * @returns an object containing the parsed out elements of `ocapURL`. + * @throws if `ocapURL` is not a well-formed ocap URL. + */ +export function parseOcapURL(ocapURL: string): OcapURLParts { + const ref = URL.parse(ocapURL); + if (!ref) { + throw Error('unparseable URL'); + } + if (ref.protocol !== 'ocap:') { + throw Error('not an ocap URL'); + } + const parts = ref.pathname.split('@'); + if (parts.length !== 2) { + throw Error('bad ocap URL'); + } + const [oid, where] = parts; + if (!where || !oid) { + throw Error('bad ocap URL'); + } + const [host, ...hints] = where.split(','); + if (!host) { + throw Error('bad ocap URL'); + } + return { + oid, + host, + hints, + }; +} + +/** + * Generate the information needed for a network identity. + * + * @param seedString - Hex string containing the key seed, or leave undefined to + * generate a new random key. + * + * @returns pair of the peer id corresponding to the given key, and the seed string. + */ +async function generateKeyInfo(seedString?: string): Promise<[string, string]> { + // eslint-disable-next-line n/no-unsupported-features/node-builtins, no-param-reassign + seedString ??= toHex(globalThis.crypto.getRandomValues(new Uint8Array(32))); + const keyPair = await generateKeyPairFromSeed('Ed25519', fromHex(seedString)); + const peerId = peerIdFromPrivateKey(keyPair).toString(); + return [seedString, peerId]; +} + +/** + * Produce a list of known libp2p STUN and TURN servers that can be used for + * NAT hole punching. + * + * @param kv - KVStore in which known relay information is kept + * + * @returns an array of multiaddrs of known relay services. + */ +export function getKnownRelays(kv: KVStore): string[] { + const knownRelays = kv.get('knownRelays'); + if (knownRelays) { + return JSON.parse(knownRelays); + } + return []; +} + +/** + * Setup temporary knowledge of a placeholder relay server. + * + * XXX TODO: This is all dummies and placeholders for developmental testing. This + * stuff MUST be replaced with actual relay configuration before distribution. + * + * @param kv - KVStore in which known relay information is kept + */ +async function setupTempTestRelays(kv: KVStore): Promise { + if (!kv.get('knownRelays')) { + const RELAY_SEED = 'c8'; + const RELAY_HOST = '/dns4/troll.fudco.com'; + + const [, relayPeerId] = await generateKeyInfo(RELAY_SEED); + const relayAddr = `${RELAY_HOST}/tcp/9001/ws/p2p/${relayPeerId}`; + const knownRelays = [relayAddr]; + kv.set('knownRelays', JSON.stringify(knownRelays)); + } +} + +// XXX IMPORTANT: All the cryptography here is completely amateur and needs to +// be vetted and most likely overhauled in its entirety by an actual competent +// cryptography expert before being unleashed on an unsuspecting public. + +/** + * Initialize remote communications for this kernel. + * + * @param kernelStore - The kernel store, for storing persistent key info. + * @param platformServices - The platform services, for accessing network I/O + * operations that are not available within the web worker that the kernel runs in. + * @param remoteMessageHandler - Handler to process received inbound communcations. + * @param logger - The logger to use. + * + * @returns the initialized remote comms object. + */ +export async function initRemoteComms( + kernelStore: KernelStore, + platformServices: PlatformServices, + remoteMessageHandler: RemoteMessageHandler, + logger?: Logger, +): Promise { + let peerId: string; + let keySeed: string; + let ocapURLKey: Uint8Array; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const { kv } = kernelStore; + + const possiblePeerId = kv.get('peerId'); + if (possiblePeerId) { + keySeed = kv.getRequired('keySeed'); + peerId = possiblePeerId; + logger?.log(`comms init: existing peer id: ${peerId}`); + } else { + // XXX TODO: Instead of generating a new random seed unconditionally, this + // function should accept an optional BIP39 keyphrase parameter for the + // seed, to enable a kernel to recover its identity on a new host. + [keySeed, peerId] = await generateKeyInfo(); + kv.set('keySeed', keySeed); + kv.set('peerId', peerId); + logger?.log(`comms init: new peer id: ${peerId}`); + } + const possibleOcapURLKey = kv.get('ocapURLKey'); + if (possibleOcapURLKey) { + ocapURLKey = fromHex(possibleOcapURLKey); + } else { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + ocapURLKey = globalThis.crypto.getRandomValues(new Uint8Array(32)); + kv.set('ocapURLKey', toHex(ocapURLKey)); + } + const cipher = AES_GCM.create(); + + await setupTempTestRelays(kv); // XXX temporary developmental scaffolding, remove asap + const knownRelays = getKnownRelays(kv); + logger?.log(`relays: ${JSON.stringify(knownRelays)}`); + await platformServices.initializeRemoteComms( + keySeed, + knownRelays, + remoteMessageHandler, + ); + + /** + * Obtain this kernel's peer ID. + * + * @returns this kernel's peer ID. + */ + function getPeerId(): string { + return peerId; + } + + /** + * Transmit a message to a remote kernel. + * + * @param to - The peer ID of the intended destination. + * @param message - The message to send; it is the caller's responsibility to + * ensure that the string properly encodes something that the recipient will + * understand. + */ + async function sendRemoteMessage(to: string, message: string): Promise { + await platformServices.sendRemoteMessage(to, message); + } + + const KREF_MIN_LEN = 16; + + /** + * Produce a URL string referencing one of the objects in this kernel. + * + * @param kref - The kref of the object in question. + * + * @returns a URL that can later be redeemed for the given object reference. + */ + async function issueOcapURL(kref: string): Promise { + // the libp2p AESCipher salts the plaintext before encrypting, so not bothering to do that here + const paddedKref = `${kref.padStart(KREF_MIN_LEN)}`; + const encodedKref = encoder.encode(paddedKref); + const rawOid = await cipher.encrypt(encodedKref, ocapURLKey); + const oid = base58btc.encode(rawOid); + const ocapURL = `ocap:${oid}@${peerId},${knownRelays.join(',')}`; + return ocapURL; + } + + /** + * Provide the kref encoded by an ocap URL referencing this kernel. + * + * @param ocapURL - The URL to be decoded. + * + * @returns a promise for the kref encoded by `ocapURL`. + * @throws if the URL is not local to this kernel. + */ + async function redeemLocalOcapURL(ocapURL: string): Promise { + const { oid, host } = parseOcapURL(ocapURL); + if (host !== peerId) { + throw Error(`ocapURL from a host that's not me`); + } + const rawOid = base58btc.decode(oid); + let encodedKref: Uint8Array; + try { + encodedKref = await cipher.decrypt(rawOid, ocapURLKey); + } catch (problem) { + logger?.error(`problem deciphering encoded kref: `, problem); + throw Error(`ocapURL has bad object reference`); + } + const paddedKref = decoder.decode(encodedKref); + const kref = paddedKref.trim(); + return kref; + } + + return { + getPeerId, + sendRemoteMessage, + issueOcapURL, + redeemLocalOcapURL, + }; +} diff --git a/packages/ocap-kernel/src/rpc/index.test.ts b/packages/ocap-kernel/src/rpc/index.test.ts index 21e773f69c..9aa4e21b95 100644 --- a/packages/ocap-kernel/src/rpc/index.test.ts +++ b/packages/ocap-kernel/src/rpc/index.test.ts @@ -7,11 +7,14 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'kernelHandlers', 'kernelMethodSpecs', + 'kernelRemoteHandlers', + 'kernelRemoteMethodSpecs', + 'platformServicesHandlers', + 'platformServicesMethodSpecs', 'vatHandlers', 'vatMethodSpecs', 'vatSyscallHandlers', 'vatSyscallMethodSpecs', - 'vatWorkerServiceMethodSpecs', ]); }); }); diff --git a/packages/ocap-kernel/src/rpc/index.ts b/packages/ocap-kernel/src/rpc/index.ts index 53882b025d..09b87a0a7a 100644 --- a/packages/ocap-kernel/src/rpc/index.ts +++ b/packages/ocap-kernel/src/rpc/index.ts @@ -1,4 +1,9 @@ export * from './kernel/index.ts'; -export * from './vat-syscall/index.ts'; -export * from './vat-worker-service/index.ts'; + +// PlatformServicesServer <-> PlatformServicesClient +export * from './platform-services/index.ts'; +export * from './kernel-remote/index.ts'; + +// VatSupervisor <-> VatHandle export * from './vat/index.ts'; +export * from './vat-syscall/index.ts'; diff --git a/packages/ocap-kernel/src/rpc/kernel-remote/index.ts b/packages/ocap-kernel/src/rpc/kernel-remote/index.ts new file mode 100644 index 0000000000..e554a1426c --- /dev/null +++ b/packages/ocap-kernel/src/rpc/kernel-remote/index.ts @@ -0,0 +1,22 @@ +import { remoteDeliverSpec, remoteDeliverHandler } from './remoteDeliver.ts'; +import type { + RemoteDeliverSpec, + RemoteDeliverHandler, +} from './remoteDeliver.ts'; + +export const kernelRemoteHandlers = { + remoteDeliver: remoteDeliverHandler, +} as { + remoteDeliver: RemoteDeliverHandler; +}; + +export const kernelRemoteMethodSpecs = { + remoteDeliver: remoteDeliverSpec, +} as { + remoteDeliver: RemoteDeliverSpec; +}; + +type Handlers = + (typeof kernelRemoteHandlers)[keyof typeof kernelRemoteHandlers]; + +export type KernelRemoteMethod = Handlers['method']; diff --git a/packages/ocap-kernel/src/rpc/kernel-remote/kernel-remote.ts b/packages/ocap-kernel/src/rpc/kernel-remote/kernel-remote.ts new file mode 100644 index 0000000000..76f93770d4 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/kernel-remote/kernel-remote.ts @@ -0,0 +1,17 @@ +import type { MethodSpec } from '@metamask/kernel-rpc-methods'; +import { object, string } from '@metamask/superstruct'; + +type RemoteDeliverParams = { + from: string; + message: string; +}; + +export const remoteDeliverSpec: MethodSpec< + 'remoteDeliver', + RemoteDeliverParams, + string +> = { + method: 'remoteDeliver', + params: object({ from: string(), message: string() }), + result: string(), +}; diff --git a/packages/ocap-kernel/src/rpc/kernel-remote/remoteDeliver.ts b/packages/ocap-kernel/src/rpc/kernel-remote/remoteDeliver.ts new file mode 100644 index 0000000000..ad11e61728 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/kernel-remote/remoteDeliver.ts @@ -0,0 +1,46 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +const paramsStruct = object({ + from: string(), + message: string(), +}); + +type Params = Infer; + +export type RemoteDeliverSpec = MethodSpec< + 'remoteDeliver', + { from: string; message: string }, + string +>; + +export const remoteDeliverSpec: RemoteDeliverSpec = { + method: 'remoteDeliver', + params: paramsStruct, + result: string(), +}; + +export type HandleRemoteDeliver = ( + from: string, + message: string, +) => Promise; + +type RemoteDeliverHooks = { + remoteDeliver: HandleRemoteDeliver; +}; + +export type RemoteDeliverHandler = Handler< + 'remoteDeliver', + Params, + Promise, + RemoteDeliverHooks +>; + +export const remoteDeliverHandler: RemoteDeliverHandler = { + ...remoteDeliverSpec, + hooks: { remoteDeliver: true }, + implementation: async ({ remoteDeliver }, params) => { + return await remoteDeliver(params.from, params.message); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/platform-services/index.ts b/packages/ocap-kernel/src/rpc/platform-services/index.ts new file mode 100644 index 0000000000..bb60f72def --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/index.ts @@ -0,0 +1,69 @@ +import { + initializeRemoteCommsSpec, + initializeRemoteCommsHandler, +} from './initializeRemoteComms.ts'; +import type { + InitializeRemoteCommsSpec, + InitializeRemoteCommsHandler, +} from './initializeRemoteComms.ts'; +import { launchSpec, launchHandler } from './launch.ts'; +import type { LaunchSpec, LaunchHandler } from './launch.ts'; +import { + sendRemoteMessageSpec, + sendRemoteMessageHandler, +} from './sendRemoteMessage.ts'; +import type { + SendRemoteMessageSpec, + SendRemoteMessageHandler, +} from './sendRemoteMessage.ts'; +import { terminateSpec, terminateHandler } from './terminate.ts'; +import type { TerminateSpec, TerminateHandler } from './terminate.ts'; +import { terminateAllSpec, terminateAllHandler } from './terminateAll.ts'; +import type { TerminateAllSpec, TerminateAllHandler } from './terminateAll.ts'; + +export const platformServicesHandlers = { + launch: launchHandler, + terminate: terminateHandler, + terminateAll: terminateAllHandler, + sendRemoteMessage: sendRemoteMessageHandler, + initializeRemoteComms: initializeRemoteCommsHandler, +} as { + launch: LaunchHandler; + terminate: TerminateHandler; + terminateAll: TerminateAllHandler; + sendRemoteMessage: SendRemoteMessageHandler; + initializeRemoteComms: InitializeRemoteCommsHandler; +}; + +export type PlatformServicesMethodSpecs = + | typeof launchSpec + | typeof terminateSpec + | typeof terminateAllSpec + | typeof sendRemoteMessageSpec + | typeof initializeRemoteCommsSpec; + +export const platformServicesMethodSpecs = { + launch: launchSpec, + terminate: terminateSpec, + terminateAll: terminateAllSpec, + sendRemoteMessage: sendRemoteMessageSpec, + initializeRemoteComms: initializeRemoteCommsSpec, +} as { + launch: LaunchSpec; + terminate: TerminateSpec; + terminateAll: TerminateAllSpec; + sendRemoteMessage: SendRemoteMessageSpec; + initializeRemoteComms: InitializeRemoteCommsSpec; +}; + +/* +export const platformServicesMethodSpecs: MethodSpecRecord = + { + launch: launchSpec, + terminate: terminateSpec, + terminateAll: terminateAllSpec, + sendRemoteMessage: sendRemoteMessageSpec, + initializeRemoteComms: initializeRemoteCommsSpec, + } as const; +*/ +export type PlatformServicesMethod = PlatformServicesMethodSpecs['method']; diff --git a/packages/ocap-kernel/src/rpc/platform-services/initializeRemoteComms.ts b/packages/ocap-kernel/src/rpc/platform-services/initializeRemoteComms.ts new file mode 100644 index 0000000000..c902140815 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/initializeRemoteComms.ts @@ -0,0 +1,48 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { array, object, literal, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +const initializeRemoteCommsParamsStruct = object({ + keySeed: string(), + knownRelays: array(string()), +}); + +type InitializeRemoteCommsParams = Infer< + typeof initializeRemoteCommsParamsStruct +>; + +export type InitializeRemoteCommsSpec = MethodSpec< + 'initializeRemoteComms', + InitializeRemoteCommsParams, + null +>; + +export const initializeRemoteCommsSpec: InitializeRemoteCommsSpec = { + method: 'initializeRemoteComms', + params: initializeRemoteCommsParamsStruct, + result: literal(null), +}; + +export type InitializeRemoteComms = ( + keySeed: string, + knownRelays: string[], +) => Promise; + +type InitializeRemoteCommsHooks = { + initializeRemoteComms: InitializeRemoteComms; +}; + +export type InitializeRemoteCommsHandler = Handler< + 'initializeRemoteComms', + InitializeRemoteCommsParams, + Promise, + InitializeRemoteCommsHooks +>; + +export const initializeRemoteCommsHandler: InitializeRemoteCommsHandler = { + ...initializeRemoteCommsSpec, + hooks: { initializeRemoteComms: true }, + implementation: async ({ initializeRemoteComms }, params) => { + return await initializeRemoteComms(params.keySeed, params.knownRelays); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/platform-services/launch.ts b/packages/ocap-kernel/src/rpc/platform-services/launch.ts new file mode 100644 index 0000000000..21e478a2b8 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/launch.ts @@ -0,0 +1,42 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { literal, object } from '@metamask/superstruct'; + +import { VatIdStruct, VatConfigStruct } from '../../types.ts'; +import type { VatId, VatConfig } from '../../types.ts'; + +type LaunchParams = { + vatId: VatId; + vatConfig: VatConfig; +}; + +export type LaunchSpec = MethodSpec<'launch', LaunchParams, null>; + +export const launchSpec: MethodSpec<'launch', LaunchParams, null> = { + method: 'launch', + params: object({ vatId: VatIdStruct, vatConfig: VatConfigStruct }), + result: literal(null), +}; + +export type HandleLaunch = ( + vatId: VatId, + vatConfig: VatConfig, +) => Promise; + +type LaunchHooks = { + launch: HandleLaunch; +}; + +export type LaunchHandler = Handler< + 'launch', + LaunchParams, + Promise, + LaunchHooks +>; + +export const launchHandler: LaunchHandler = { + ...launchSpec, + hooks: { launch: true }, + implementation: async ({ launch }, params) => { + return await launch(params.vatId, params.vatConfig); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/platform-services/sendRemoteMessage.ts b/packages/ocap-kernel/src/rpc/platform-services/sendRemoteMessage.ts new file mode 100644 index 0000000000..5b46f189ad --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/sendRemoteMessage.ts @@ -0,0 +1,43 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { object, literal, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +const sendRemoteMessageParamsStruct = object({ + to: string(), + message: string(), +}); + +type SendRemoteMessageParams = Infer; + +export type SendRemoteMessageSpec = MethodSpec< + 'sendRemoteMessage', + SendRemoteMessageParams, + null +>; + +export const sendRemoteMessageSpec: SendRemoteMessageSpec = { + method: 'sendRemoteMessage', + params: sendRemoteMessageParamsStruct, + result: literal(null), +}; + +export type SendRemoteMessage = (to: string, message: string) => Promise; + +type SendRemoteMessageHooks = { + sendRemoteMessage: SendRemoteMessage; +}; + +export type SendRemoteMessageHandler = Handler< + 'sendRemoteMessage', + SendRemoteMessageParams, + Promise, + SendRemoteMessageHooks +>; + +export const sendRemoteMessageHandler: SendRemoteMessageHandler = { + ...sendRemoteMessageSpec, + hooks: { sendRemoteMessage: true }, + implementation: async ({ sendRemoteMessage }, params) => { + return await sendRemoteMessage(params.to, params.message); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/platform-services/terminate.ts b/packages/ocap-kernel/src/rpc/platform-services/terminate.ts new file mode 100644 index 0000000000..f3e9b79630 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/terminate.ts @@ -0,0 +1,39 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { object, literal } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import { VatIdStruct } from '../../types.ts'; +import type { VatId } from '../../types.ts'; + +const terminateParamsStruct = object({ vatId: VatIdStruct }); + +type TerminateParams = Infer; + +export type TerminateSpec = MethodSpec<'terminate', TerminateParams, null>; + +export const terminateSpec: TerminateSpec = { + method: 'terminate', + params: terminateParamsStruct, + result: literal(null), +}; + +export type Terminate = (vatId: VatId) => Promise; + +type TerminateHooks = { + terminate: Terminate; +}; + +export type TerminateHandler = Handler< + 'terminate', + TerminateParams, + Promise, + TerminateHooks +>; + +export const terminateHandler: TerminateHandler = { + ...terminateSpec, + hooks: { terminate: true }, + implementation: async ({ terminate }, params) => { + return await terminate(params.vatId); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/platform-services/terminateAll.ts b/packages/ocap-kernel/src/rpc/platform-services/terminateAll.ts new file mode 100644 index 0000000000..599e4a1d23 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/platform-services/terminateAll.ts @@ -0,0 +1,41 @@ +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import { EmptyJsonArray } from '@metamask/kernel-utils'; +import { literal } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +const terminateAllParamsStruct = EmptyJsonArray; + +type TerminateAllParams = Json[]; + +export type TerminateAllSpec = MethodSpec< + 'terminateAll', + TerminateAllParams, + null +>; + +export const terminateAllSpec: TerminateAllSpec = { + method: 'terminateAll', + params: terminateAllParamsStruct, + result: literal(null), +}; + +export type TerminateAll = () => Promise; + +type TerminateAllHooks = { + terminateAll: TerminateAll; +}; + +export type TerminateAllHandler = Handler< + 'terminateAll', + TerminateAllParams, + Promise, + TerminateAllHooks +>; + +export const terminateAllHandler: TerminateAllHandler = { + ...terminateAllSpec, + hooks: { terminateAll: true }, + implementation: async ({ terminateAll }, _params) => { + return await terminateAll(); + }, +}; diff --git a/packages/ocap-kernel/src/rpc/vat-worker-service/index.ts b/packages/ocap-kernel/src/rpc/vat-worker-service/index.ts deleted file mode 100644 index eb1dc37f75..0000000000 --- a/packages/ocap-kernel/src/rpc/vat-worker-service/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { MethodSpecRecord } from '@metamask/kernel-rpc-methods'; - -import { launchSpec } from './launch.ts'; -import { terminateSpec } from './terminate.ts'; -import { terminateAllSpec } from './terminateAll.ts'; - -// This module only has method specifications and no handlers, because the method -// implementations are highly platform-specific and do not warrant standalone -// implementations. - -export type VatWorkerServiceMethodSpecs = - | typeof launchSpec - | typeof terminateSpec - | typeof terminateAllSpec; - -export const vatWorkerServiceMethodSpecs: MethodSpecRecord = - { - launch: launchSpec, - terminate: terminateSpec, - terminateAll: terminateAllSpec, - } as const; - -export type VatWorkerServiceMethod = VatWorkerServiceMethodSpecs['method']; diff --git a/packages/ocap-kernel/src/rpc/vat-worker-service/launch.ts b/packages/ocap-kernel/src/rpc/vat-worker-service/launch.ts deleted file mode 100644 index 65f052d32b..0000000000 --- a/packages/ocap-kernel/src/rpc/vat-worker-service/launch.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { MethodSpec } from '@metamask/kernel-rpc-methods'; -import { literal, object } from '@metamask/superstruct'; - -import { VatIdStruct, VatConfigStruct } from '../../types.ts'; -import type { VatId, VatConfig } from '../../types.ts'; - -type LaunchParams = { - vatId: VatId; - vatConfig: VatConfig; -}; - -export const launchSpec: MethodSpec<'launch', LaunchParams, null> = { - method: 'launch', - params: object({ vatId: VatIdStruct, vatConfig: VatConfigStruct }), - result: literal(null), -}; diff --git a/packages/ocap-kernel/src/rpc/vat-worker-service/terminate.ts b/packages/ocap-kernel/src/rpc/vat-worker-service/terminate.ts deleted file mode 100644 index 117850ac02..0000000000 --- a/packages/ocap-kernel/src/rpc/vat-worker-service/terminate.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MethodSpec } from '@metamask/kernel-rpc-methods'; -import { object, literal } from '@metamask/superstruct'; - -import { VatIdStruct } from '../../types.ts'; -import type { VatId } from '../../types.ts'; - -export const terminateSpec: MethodSpec<'terminate', { vatId: VatId }, null> = { - method: 'terminate', - params: object({ vatId: VatIdStruct }), - result: literal(null), -}; diff --git a/packages/ocap-kernel/src/rpc/vat-worker-service/terminateAll.ts b/packages/ocap-kernel/src/rpc/vat-worker-service/terminateAll.ts deleted file mode 100644 index fdf5f794b1..0000000000 --- a/packages/ocap-kernel/src/rpc/vat-worker-service/terminateAll.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MethodSpec } from '@metamask/kernel-rpc-methods'; -import { EmptyJsonArray } from '@metamask/kernel-utils'; -import { literal } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; - -export const terminateAllSpec: MethodSpec<'terminateAll', Json[], null> = { - method: 'terminateAll', - params: EmptyJsonArray, - result: literal(null), -}; diff --git a/packages/ocap-kernel/src/services/garbage-collection.test.ts b/packages/ocap-kernel/src/services/garbage-collection.test.ts index 79a34f3b4d..bca1752652 100644 --- a/packages/ocap-kernel/src/services/garbage-collection.test.ts +++ b/packages/ocap-kernel/src/services/garbage-collection.test.ts @@ -35,7 +35,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ type: 'dropExports', - vatId: 'v1', + endpointId: 'v1', krefs: [ko1], }); @@ -56,7 +56,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ type: 'retireExports', - vatId: 'v1', + endpointId: 'v1', krefs: [ko1], }); @@ -76,7 +76,7 @@ describe('garbage-collection', () => { // Verify result expect(result).toStrictEqual({ type: 'retireImports', - vatId: 'v2', + endpointId: 'v2', krefs: [ko1], }); @@ -106,7 +106,7 @@ describe('garbage-collection', () => { let result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ type: 'dropExports', - vatId: 'v1', + endpointId: 'v1', krefs: [ko1], }); @@ -114,7 +114,7 @@ describe('garbage-collection', () => { result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ type: 'retireExports', - vatId: 'v1', + endpointId: 'v1', krefs: [ko2], }); }); @@ -141,7 +141,7 @@ describe('garbage-collection', () => { let result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ type: 'dropExports', - vatId: 'v1', + endpointId: 'v1', krefs: [ko2], }); @@ -149,7 +149,7 @@ describe('garbage-collection', () => { result = processGCActionSet(kernelStore); expect(result).toStrictEqual({ type: 'dropExports', - vatId: 'v2', + endpointId: 'v2', krefs: [ko1], }); }); diff --git a/packages/ocap-kernel/src/services/garbage-collection.ts b/packages/ocap-kernel/src/services/garbage-collection.ts index 37c64eeceb..8b5b9aa9b7 100644 --- a/packages/ocap-kernel/src/services/garbage-collection.ts +++ b/packages/ocap-kernel/src/services/garbage-collection.ts @@ -172,7 +172,7 @@ export function processGCActionSet( const queueType = queueTypeFromActionType.get(type); assert(queueType !== undefined, `Unknown action type: ${type}`); - return harden({ type: queueType, vatId, krefs }); + return harden({ type: queueType, endpointId: vatId, krefs }); } } } diff --git a/packages/ocap-kernel/src/services/gc-finalize.ts b/packages/ocap-kernel/src/services/gc-finalize.ts index 1ee01ed1e2..fc78051eec 100644 --- a/packages/ocap-kernel/src/services/gc-finalize.ts +++ b/packages/ocap-kernel/src/services/gc-finalize.ts @@ -1,6 +1,8 @@ import { delay } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; +type GCFunction = () => void; + /** * Try to get a GC function for the current environment * @@ -9,7 +11,7 @@ import type { Logger } from '@metamask/logger'; */ async function getGCFunction(logger?: Logger): Promise { if (typeof globalThis.gc === 'function') { - return globalThis.gc; + return globalThis.gc as GCFunction; } // Check if we're in Node.js @@ -20,7 +22,7 @@ async function getGCFunction(logger?: Logger): Promise { try { // Dynamic import of Node.js specific module so it's not included in browser builds const { engineGC } = await import('./gc-engine.ts'); - return engineGC; + return engineGC as GCFunction; } catch (error) { logger?.debug('Failed to load Node.js GC implementation:', error); } diff --git a/packages/ocap-kernel/src/store/index.test.ts b/packages/ocap-kernel/src/store/index.test.ts index 8a58bafd39..f104a39770 100644 --- a/packages/ocap-kernel/src/store/index.test.ts +++ b/packages/ocap-kernel/src/store/index.test.ts @@ -67,7 +67,7 @@ describe('kernel store', () => { 'enqueuePromiseMessage', 'enqueueRun', 'erefToKref', - 'exportFromVat', + 'exportFromEndpoint', 'forgetEref', 'forgetKref', 'forgetTerminatedVat', @@ -104,6 +104,7 @@ describe('kernel store', () => { 'initEndpoint', 'initKernelObject', 'initKernelPromise', + 'invertRRef', 'isObjectPinned', 'isRevoked', 'isRootObject', @@ -133,9 +134,12 @@ describe('kernel store', () => { 'setRevoked', 'setVatConfig', 'startCrank', - 'translateCapDataKtoV', - 'translateMessageKtoV', - 'translateRefKtoV', + 'translateCapDataEtoK', + 'translateCapDataKtoE', + 'translateMessageEtoK', + 'translateMessageKtoE', + 'translateRefEtoK', + 'translateRefKtoE', 'translateSyscallVtoK', 'unpinObject', 'waitForCrank', diff --git a/packages/ocap-kernel/src/store/methods/clist.ts b/packages/ocap-kernel/src/store/methods/clist.ts index a2e09c208e..95661562b2 100644 --- a/packages/ocap-kernel/src/store/methods/clist.ts +++ b/packages/ocap-kernel/src/store/methods/clist.ts @@ -115,7 +115,7 @@ export function getCListMethods(ctx: StoreContext) { } /** - * Look up the KRef that and endpoint's c-list maps an ERef to. + * Look up the KRef that an endpoint's c-list maps an ERef to. * * @param endpointId - The endpoint in question. * @param kref - The KRef to look up. @@ -133,7 +133,7 @@ export function getCListMethods(ctx: StoreContext) { } /** - * Look up the ERef that and endpoint's c-list maps a KRef to. + * Look up the ERefs that an endpoint's c-list maps aa list of KRefs to. * * @param endpointId - The endpoint in question. * @param krefs - The KRefs to look up. diff --git a/packages/ocap-kernel/src/store/methods/gc.test.ts b/packages/ocap-kernel/src/store/methods/gc.test.ts index 2c0cb8e8e4..fff52c2700 100644 --- a/packages/ocap-kernel/src/store/methods/gc.test.ts +++ b/packages/ocap-kernel/src/store/methods/gc.test.ts @@ -13,14 +13,14 @@ describe('GC methods', () => { describe('GC actions', () => { it('manages all valid GC action types', () => { - const ko1 = kernelStore.initKernelObject('v1'); - const ko2 = kernelStore.initKernelObject('v1'); - const ko3 = kernelStore.initKernelObject('v2'); + const v1Object = kernelStore.initKernelObject('v1'); + const v2Object = kernelStore.initKernelObject('v1'); + const v3Object = kernelStore.initKernelObject('v2'); const validActions: GCAction[] = [ - `v1 dropExport ${ko1}`, - `v1 retireExport ${ko2}`, - `v2 retireImport ${ko3}`, + `v1 dropExport ${v1Object}`, + `v1 retireExport ${v2Object}`, + `v2 retireImport ${v3Object}`, ]; kernelStore.addGCActions(validActions); @@ -31,16 +31,18 @@ describe('GC methods', () => { }); it('rejects invalid GC actions', () => { - const ko1 = kernelStore.initKernelObject('v1'); + const v1Object = kernelStore.initKernelObject('v1'); // Invalid vat ID expect(() => { - kernelStore.addGCActions(['x1 dropExport ko1']); + kernelStore.addGCActions([`x1 dropExport ${v1Object}`]); }).toThrow('not a valid VatId'); // Invalid action type expect(() => { - kernelStore.addGCActions([`v1 invalidAction ${ko1}`] as GCAction[]); + kernelStore.addGCActions([ + `v1 invalidAction ${v1Object}`, + ] as GCAction[]); }).toThrow('not a valid GCActionType "invalidAction"'); // Invalid kref (must be kernel object, not promise) @@ -55,14 +57,14 @@ describe('GC methods', () => { }); it('maintains action order when storing', () => { - const ko1 = kernelStore.initKernelObject('v1'); - const ko2 = kernelStore.initKernelObject('v2'); - const ko3 = kernelStore.initKernelObject('v3'); + const v1Object = kernelStore.initKernelObject('v1'); + const v2Object = kernelStore.initKernelObject('v2'); + const v3Object = kernelStore.initKernelObject('v3'); const actions = [ - `v3 retireImport ${ko3}`, - `v1 dropExport ${ko1}`, - `v2 retireExport ${ko2}`, + `v3 retireImport ${v3Object}`, + `v1 dropExport ${v1Object}`, + `v2 retireExport ${v2Object}`, ]; kernelStore.setGCActions(new Set(actions) as Set); @@ -70,24 +72,24 @@ describe('GC methods', () => { // Actions should be sorted when retrieved const sortedActions = Array.from(kernelStore.getGCActions()); expect(sortedActions).toStrictEqual([ - `v1 dropExport ${ko1}`, - `v2 retireExport ${ko2}`, - `v3 retireImport ${ko3}`, + `v1 dropExport ${v1Object}`, + `v2 retireExport ${v2Object}`, + `v3 retireImport ${v3Object}`, ]); }); }); describe('reachability tracking', () => { it('manages reachable flags', () => { - const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addCListEntry('v1', ko1, 'o-1'); + const v1Object = kernelStore.initKernelObject('v1'); + kernelStore.addCListEntry('v1', v1Object, 'o-1'); - expect(kernelStore.getReachableFlag('v1', ko1)).toBe(true); + expect(kernelStore.getReachableFlag('v1', v1Object)).toBe(true); - kernelStore.clearReachableFlag('v1', ko1); - expect(kernelStore.getReachableFlag('v1', ko1)).toBe(false); + kernelStore.clearReachableFlag('v1', v1Object); + expect(kernelStore.getReachableFlag('v1', v1Object)).toBe(false); - const refCounts = kernelStore.getObjectRefCount(ko1); + const refCounts = kernelStore.getObjectRefCount(v1Object); expect(refCounts.reachable).toBe(0); }); }); @@ -100,10 +102,10 @@ describe('GC methods', () => { vatIds.forEach((vatId) => kernelStore.scheduleReap(vatId)); // Verify they are processed in order - vatIds.forEach((vatId) => { + vatIds.forEach((endpointId) => { expect(kernelStore.nextReapAction()).toStrictEqual({ type: 'bringOutYourDead', - vatId, + endpointId, }); }); @@ -119,12 +121,12 @@ describe('GC methods', () => { // Should only process v1 once expect(kernelStore.nextReapAction()).toStrictEqual({ type: 'bringOutYourDead', - vatId: 'v1', + endpointId: 'v1', }); expect(kernelStore.nextReapAction()).toStrictEqual({ type: 'bringOutYourDead', - vatId: 'v2', + endpointId: 'v2', }); expect(kernelStore.nextReapAction()).toBeUndefined(); diff --git a/packages/ocap-kernel/src/store/methods/gc.ts b/packages/ocap-kernel/src/store/methods/gc.ts index 4e3641917e..6d518bc1a9 100644 --- a/packages/ocap-kernel/src/store/methods/gc.ts +++ b/packages/ocap-kernel/src/store/methods/gc.ts @@ -92,9 +92,9 @@ export function getGCMethods(ctx: StoreContext) { function nextReapAction(): RunQueueItemBringOutYourDead | undefined { const queue = JSON.parse(ctx.reapQueue.get() ?? '[]'); if (queue.length > 0) { - const vatId = queue.shift(); + const endpointId = queue.shift(); ctx.reapQueue.set(JSON.stringify(queue)); - return harden({ type: 'bringOutYourDead', vatId }); + return harden({ type: 'bringOutYourDead', endpointId }); } return undefined; } @@ -148,6 +148,9 @@ export function getGCMethods(ctx: StoreContext) { if (reachable === 0) { const ownerKey = getOwnerKey(kref); let ownerVatID = ctx.kv.get(ownerKey); + if (ownerVatID === 'kernel') { + continue; + } const terminated = isVatTerminated(ownerVatID as VatId); // Some objects that are still owned, but the owning vat diff --git a/packages/ocap-kernel/src/store/methods/promise.ts b/packages/ocap-kernel/src/store/methods/promise.ts index 5d43c5607e..202fd857da 100644 --- a/packages/ocap-kernel/src/store/methods/promise.ts +++ b/packages/ocap-kernel/src/store/methods/promise.ts @@ -10,8 +10,9 @@ import type { PromiseState, RunQueueItemSend, VatId, + EndpointId, } from '../../types.ts'; -import { insistVatId } from '../../types.ts'; +import { insistEndpointId } from '../../types.ts'; import type { StoreContext } from '../types.ts'; import { getRefCountMethods } from './refcount.ts'; import { makeKernelSlot } from '../utils/kernel-slots.ts'; @@ -122,16 +123,16 @@ export function getPromiseMethods(ctx: StoreContext) { /** * Add a new subscriber to a kernel promise's collection of subscribers. * - * @param vatId - The vat that is subscribing. + * @param endpointId - The endpoint that is subscribing. * @param kpid - The KRef of the promise being subscribed to. */ - function addPromiseSubscriber(vatId: VatId, kpid: KRef): void { - insistVatId(vatId); + function addPromiseSubscriber(endpointId: EndpointId, kpid: KRef): void { + insistEndpointId(endpointId); const kp = getKernelPromise(kpid); kp.state === 'unresolved' || Fail`attempt to add subscriber to resolved promise ${kpid}`; const tempSet = new Set(kp.subscribers); - tempSet.add(vatId); + tempSet.add(endpointId); const newSubscribers = Array.from(tempSet).sort(); const key = `${kpid}.subscribers`; ctx.kv.set(key, JSON.stringify(newSubscribers)); @@ -141,14 +142,14 @@ export function getPromiseMethods(ctx: StoreContext) { * Assign a kernel promise's decider. * * @param kpid - The KRef of promise whose decider is being set. - * @param vatId - The vat which will become the decider. + * @param endpointId - The endpoint which will become the decider. */ - function setPromiseDecider(kpid: KRef, vatId: VatId): void { - if (vatId !== 'kernel') { - insistVatId(vatId); + function setPromiseDecider(kpid: KRef, endpointId: EndpointId): void { + if (endpointId !== 'kernel') { + insistEndpointId(endpointId); } if (kpid) { - ctx.kv.set(`${kpid}.decider`, vatId); + ctx.kv.set(`${kpid}.decider`, endpointId); } } diff --git a/packages/ocap-kernel/src/store/methods/refcount.ts b/packages/ocap-kernel/src/store/methods/refcount.ts index 8d3996aa3d..b87d13c253 100644 --- a/packages/ocap-kernel/src/store/methods/refcount.ts +++ b/packages/ocap-kernel/src/store/methods/refcount.ts @@ -158,7 +158,6 @@ export function getRefCountMethods(ctx: StoreContext) { } ctx.logger?.debug('--', refCountKey(kref), JSON.stringify(counts), tag); setObjectRefCount(kref, counts); - ctx.kv.set('initialized', 'true'); return false; } diff --git a/packages/ocap-kernel/src/store/methods/translators.test.ts b/packages/ocap-kernel/src/store/methods/translators.test.ts index 8fb8448a3b..2cc982c5d3 100644 --- a/packages/ocap-kernel/src/store/methods/translators.test.ts +++ b/packages/ocap-kernel/src/store/methods/translators.test.ts @@ -16,7 +16,7 @@ describe('getTranslators', () => { const mockKrefToEref = vi.fn(); const mockErefToKref = vi.fn(); const mockAllocateErefForKref = vi.fn(); - const mockExportFromVat = vi.fn(); + const mockExportFromEndpoint = vi.fn(); const mockCtx = {} as StoreContext; beforeEach(() => { @@ -29,18 +29,18 @@ describe('getTranslators', () => { } as unknown as ReturnType); vi.spyOn(vatModule, 'getVatMethods').mockReturnValue({ - exportFromVat: mockExportFromVat, + exportFromEndpoint: mockExportFromEndpoint, } as unknown as ReturnType); }); - describe('translateRefKtoV', () => { + describe('translateRefKtoE', () => { it('returns existing eref when found', () => { const vatId: VatId = 'v1'; const kref: KRef = 'k1'; const expectedEref: VRef = 'e1'; mockKrefToEref.mockReturnValue(expectedEref); - const { translateRefKtoV } = getTranslators(mockCtx); - const result = translateRefKtoV(vatId, kref, false); + const { translateRefKtoE } = getTranslators(mockCtx); + const result = translateRefKtoE(vatId, kref, false); expect(mockKrefToEref).toHaveBeenCalledWith(vatId, kref); expect(result).toStrictEqual(expectedEref); expect(mockAllocateErefForKref).not.toHaveBeenCalled(); @@ -52,8 +52,8 @@ describe('getTranslators', () => { const expectedEref: VRef = 'e1'; mockKrefToEref.mockReturnValue(null); mockAllocateErefForKref.mockReturnValue(expectedEref); - const { translateRefKtoV } = getTranslators(mockCtx); - const result = translateRefKtoV(vatId, kref, true); + const { translateRefKtoE } = getTranslators(mockCtx); + const result = translateRefKtoE(vatId, kref, true); expect(mockKrefToEref).toHaveBeenCalledWith(vatId, kref); expect(mockAllocateErefForKref).toHaveBeenCalledWith(vatId, kref); expect(result).toStrictEqual(expectedEref); @@ -63,14 +63,14 @@ describe('getTranslators', () => { const vatId: VatId = 'v1'; const kref: KRef = 'k1'; mockKrefToEref.mockReturnValue(null); - const { translateRefKtoV } = getTranslators(mockCtx); - expect(() => translateRefKtoV(vatId, kref, false)).toThrow( - `unmapped kref "${kref}" vat="${vatId}"`, + const { translateRefKtoE } = getTranslators(mockCtx); + expect(() => translateRefKtoE(vatId, kref, false)).toThrow( + `unmapped kref "${kref}" endpoint="${vatId}"`, ); }); }); - describe('translateCapDataKtoV', () => { + describe('translateCapDataKtoE', () => { it('translates capdata from kernel to vat space', () => { const vatId: VatId = 'v1'; const kref1: KRef = 'k1'; @@ -94,15 +94,15 @@ describe('getTranslators', () => { } return null; }); - const { translateCapDataKtoV } = getTranslators(mockCtx); - const result = translateCapDataKtoV(vatId, capdata); + const { translateCapDataKtoE } = getTranslators(mockCtx); + const result = translateCapDataKtoE(vatId, capdata); expect(result).toStrictEqual(expectedCapData); expect(mockKrefToEref).toHaveBeenCalledWith(vatId, kref1); expect(mockKrefToEref).toHaveBeenCalledWith(vatId, kref2); }); }); - describe('translateMessageKtoV', () => { + describe('translateMessageKtoE', () => { it('translates message from kernel to vat space', () => { const vatId: VatId = 'v1'; const kref: KRef = 'k1'; @@ -132,9 +132,9 @@ describe('getTranslators', () => { } as unknown as CapData, result: resultEref, }; - const { translateMessageKtoV } = getTranslators(mockCtx); + const { translateMessageKtoE } = getTranslators(mockCtx); // @ts-expect-error: Message result is optional - const result = translateMessageKtoV(vatId, message); + const result = translateMessageKtoE(vatId, message); expect(result).toStrictEqual(expectedMessage); }); @@ -162,9 +162,9 @@ describe('getTranslators', () => { } as unknown as CapData, result: null, }; - const { translateMessageKtoV } = getTranslators(mockCtx); + const { translateMessageKtoE } = getTranslators(mockCtx); // @ts-expect-error: Message result is optional - const result = translateMessageKtoV(vatId, message); + const result = translateMessageKtoE(vatId, message); expect(result).toStrictEqual(expectedMessage); }); }); @@ -185,7 +185,7 @@ describe('getTranslators', () => { } return null; }); - mockExportFromVat.mockImplementation((_vId, vr) => { + mockExportFromEndpoint.mockImplementation((_vId, vr) => { return `exported-${vr}`; }); }); diff --git a/packages/ocap-kernel/src/store/methods/translators.ts b/packages/ocap-kernel/src/store/methods/translators.ts index f8f764b1c6..3b9f28c6f3 100644 --- a/packages/ocap-kernel/src/store/methods/translators.ts +++ b/packages/ocap-kernel/src/store/methods/translators.ts @@ -4,16 +4,24 @@ import type { } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; -import { coerceMessage } from '../../types.ts'; -import type { Message, VatId, KRef, VRef } from '../../types.ts'; +import { coerceMessage, isRemoteId } from '../../types.ts'; +import type { + Message, + VatId, + EndpointId, + KRef, + VRef, + RRef, + ERef, +} from '../../types.ts'; import type { StoreContext } from '../types.ts'; import { getCListMethods } from './clist.ts'; import { getVatMethods } from './vat.ts'; -import { Fail } from '../../utils/assert.ts'; +import { Fail, assert } from '../../utils/assert.ts'; /** * Create a translator object that provides functionality for translating - * references and messages between kernel and vat spaces. + * references and messages between kernel and endpoint spaces. * * @param ctx - The store context. * @returns A translator object that maps various kernel data structures @@ -22,123 +30,166 @@ import { Fail } from '../../utils/assert.ts'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function getTranslators(ctx: StoreContext) { const { krefToEref, erefToKref, allocateErefForKref } = getCListMethods(ctx); - const { exportFromVat } = getVatMethods(ctx); + const { exportFromEndpoint } = getVatMethods(ctx); /** - * Translate a reference from kernel space into vat space. + * Reverse the direction indicator in an RRef. * - * @param vatId - The vat for whom translation is desired. + * @param rref - The ref in question. + * + * @returns a copy of `rref` with '+'/'-' changed to '-'/'+'. + */ + function invertRRef(rref: RRef): RRef { + // eslint-disable-next-line require-unicode-regexp + const parts = rref.match(/^(r[op])([-+])(\d+)$/); + assert(parts?.length === 4); + return `${parts[1]}${parts[2] === '+' ? '-' : '+'}${parts[3]}`; + } + + /** + * Translate a reference from kernel space into endpoint space. + * + * @param endpointId - The endpoint for whom translation is desired. * @param kref - The KRef of the entity of interest. * @param importIfNeeded - If true, allocate a new clist entry if necessary; * if false, require that such an entry already exist. * - * @returns the VRef corresponding to `kref` in `vatId`. + * @returns the ERef corresponding to `kref` in `endpointId`. */ - function translateRefKtoV( - vatId: VatId, + function translateRefKtoE( + endpointId: EndpointId, kref: KRef, importIfNeeded: boolean, - ): VRef { - let eref = krefToEref(vatId, kref); + ): ERef { + let eref = krefToEref(endpointId, kref); if (!eref) { if (importIfNeeded) { - eref = allocateErefForKref(vatId, kref); + eref = allocateErefForKref(endpointId, kref); } else { - throw Fail`unmapped kref ${kref} vat=${vatId}`; + throw Fail`unmapped kref ${kref} endpoint=${endpointId}`; } } + if (isRemoteId(endpointId)) { + // The import/export relationship between a vat and the kernel is + // asymmetric -- the vat always exports to the kernel and imports from the + // kernel. This is reflected in the string encoding of a VRef, where a '+' + // indicates an export (vat to kernel) and a '-' indicates an import + // (kernel to vat). However, the relationship between two remotes is + // symmetric -- an export from one is an import to the other and vice + // versa. We thus require a convention for interpreting an RRef's + // directionality encoding to break the symmetry. The trick (courtesy of + // Brian Warner) is to always interpet an RRef in the context of the + // receiving endpoint. This means that when communicating an RRef (which + // is implied by the fact that we're using a KtoE translation) we need to + // flip the polarity of the character that encodes the import/export + // direction to convert it from the sender's frame of reference to the + // receiver's. + // + // Care must be taken when using these reference translation functions for + // something other than communications (debugging or logging, say), since + // whereas for a VRef the functional composition KtoE(EtoK(ref)) is an + // identity function, for an RRef it is not, because KtoE reverses the + // polarity of the RRef but EtoK doesn't. + eref = invertRRef(eref); + } return eref; } /** - * Translate a capdata object from kernel space into vat space. + * Translate a capdata object from kernel space into endpoint space. * - * @param vatId - The vat for whom translation is desired. + * @param endpointId - The endpoint for whom translation is desired. * @param capdata - The object to be translated. * - * @returns a translated copy of `capdata` intelligible to `vatId`. + * @returns a translated copy of `capdata` intelligible to `endpointId`. */ - function translateCapDataKtoV( - vatId: VatId, + function translateCapDataKtoE( + endpointId: EndpointId, capdata: CapData, - ): CapData { - const slots: VRef[] = []; + ): CapData { + const slots: ERef[] = []; for (const slot of capdata.slots) { - slots.push(translateRefKtoV(vatId, slot, true)); + slots.push(translateRefKtoE(endpointId, slot, true)); } return { body: capdata.body, slots }; } /** - * Translate a message from kernel space into vat space. + * Translate a message from kernel space into endpoint space. * - * @param vatId - The vat for whom translation is desired. + * @param endpointId - The endpoint for whom translation is desired. * @param message - The message to be translated. * - * @returns a translated copy of `message` intelligible to `vatId`. + * @returns a translated copy of `message` intelligible to `endpointId`. */ - function translateMessageKtoV(vatId: VatId, message: Message): Message { - const methargs = translateCapDataKtoV( - vatId, + function translateMessageKtoE( + endpointId: EndpointId, + message: Message, + ): Message { + const methargs = translateCapDataKtoE( + endpointId, message.methargs as CapData, ); const result = message.result - ? translateRefKtoV(vatId, message.result, true) + ? translateRefKtoE(endpointId, message.result, true) : message.result; - const vatMessage = coerceMessage({ ...message, methargs, result }); - return vatMessage; + const endpointMessage = coerceMessage({ ...message, methargs, result }); + return endpointMessage; } /** - * Translate a reference from vat space into kernel space. + * Translate a reference from endpoint space into kernel space. * - * @param vatId - The vat for whom translation is desired. - * @param vref - The VRef of the entity of interest. + * @param endpointId - The endpoint for whom translation is desired. + * @param eref - The ERef of the entity of interest. * - * @returns the KRef corresponding to `vref` in this vat. + * @returns the KRef corresponding to `eref` in this endpoint. */ - function translateRefVtoK(vatId: VatId, vref: VRef): KRef { - let kref = erefToKref(vatId, vref); - kref ??= exportFromVat(vatId, vref); + function translateRefEtoK(endpointId: EndpointId, eref: ERef): KRef { + let kref = erefToKref(endpointId, eref); + kref ??= exportFromEndpoint(endpointId, eref); return kref; } /** - * Translate a capdata object from vat space into kernel space. + * Translate a capdata object from endpoint space into kernel space. * - * @param vatId - The vat for whom translation is desired. + * @param endpointId - The endpoint for whom translation is desired. * @param capdata - The object to be translated. * * @returns a translated copy of `capdata` intelligible to the kernel. */ - function translateCapDataVtoK( - vatId: VatId, - capdata: CapData, + function translateCapDataEtoK( + endpointId: EndpointId, + capdata: CapData, ): CapData { const slots: KRef[] = []; for (const slot of capdata.slots) { - slots.push(translateRefVtoK(vatId, slot)); + slots.push(translateRefEtoK(endpointId, slot)); } return { body: capdata.body, slots }; } /** - * Translate a message from vat space into kernel space. + * Translate a message from endpoint space into kernel space. * - * @param vatId - The vat for whom translation is desired. + * @param endpointId - The endpoint for whom translation is desired. * @param message - The message to be translated. * * @returns a translated copy of `message` intelligible to the kernel. */ - function translateMessageVtoK(vatId: VatId, message: Message): Message { - const methargs = translateCapDataVtoK( - vatId, - message.methargs as CapData, + function translateMessageEtoK( + endpointId: EndpointId, + message: Message, + ): Message { + const methargs = translateCapDataEtoK( + endpointId, + message.methargs as CapData, ); if (typeof message.result !== 'string') { throw TypeError(`message result must be a string`); } - const result = translateRefVtoK(vatId, message.result); + const result = translateRefEtoK(endpointId, message.result); return { methargs, result }; } @@ -161,17 +212,17 @@ export function getTranslators(ctx: StoreContext) { const [op, target, message] = vso; kso = [ op, - translateRefVtoK(vatId, target), + translateRefEtoK(vatId, target), // @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)), + translateMessageEtoK(vatId, coerceMessage(message)), ]; break; } case 'subscribe': { // [VRef]; const [op, promise] = vso; - kso = [op, translateRefVtoK(vatId, promise)]; + kso = [op, translateRefEtoK(vatId, promise)]; break; } case 'resolve': { @@ -181,9 +232,9 @@ export function getTranslators(ctx: StoreContext) { (resolution) => { const [vpid, rejected, data] = resolution; return [ - translateRefVtoK(vatId, vpid), + translateRefEtoK(vatId, vpid), rejected, - translateCapDataVtoK(vatId, data as CapData), + translateCapDataEtoK(vatId, data as CapData), ]; }, ); @@ -196,7 +247,7 @@ export function getTranslators(ctx: StoreContext) { kso = [ op, isFailure, - translateCapDataVtoK(vatId, info as CapData), + translateCapDataEtoK(vatId, info as CapData), ]; break; } @@ -206,7 +257,7 @@ export function getTranslators(ctx: StoreContext) { case 'abandonExports': { // [VRef[]]; const [op, vrefs] = vso; - const krefs = vrefs.map((ref) => translateRefVtoK(vatId, ref)); + const krefs = vrefs.map((ref) => translateRefEtoK(vatId, ref)); kso = [op, krefs]; break; } @@ -228,9 +279,13 @@ export function getTranslators(ctx: StoreContext) { } return { - translateRefKtoV, - translateCapDataKtoV, - translateMessageKtoV, + translateRefEtoK, + translateRefKtoE, + translateCapDataKtoE, + translateCapDataEtoK, + translateMessageKtoE, + translateMessageEtoK, translateSyscallVtoK, + invertRRef, }; } diff --git a/packages/ocap-kernel/src/store/methods/vat.test.ts b/packages/ocap-kernel/src/store/methods/vat.test.ts index 4a9ec69063..527a0a0f19 100644 --- a/packages/ocap-kernel/src/store/methods/vat.test.ts +++ b/packages/ocap-kernel/src/store/methods/vat.test.ts @@ -450,12 +450,12 @@ describe('vat store methods', () => { }); }); - describe('exportFromVat', () => { + describe('exportFromEndpoint', () => { it('creates a kernel promise for an exported promise', () => { const vatId = 'v1' as VatId; const vref = 'p+42'; // Promise export reference - const result = vatMethods.exportFromVat(vatId, vref); + const result = vatMethods.exportFromEndpoint(vatId, vref); expect(result).toBe('kp123'); expect(mockInitKernelPromise).toHaveBeenCalled(); @@ -471,7 +471,7 @@ describe('vat store methods', () => { const vatId = 'v1' as VatId; const vref = 'o+42'; // Object export reference - const result = vatMethods.exportFromVat(vatId, vref); + const result = vatMethods.exportFromEndpoint(vatId, vref); expect(result).toBe('ko456'); expect(mockInitKernelObject).toHaveBeenCalledWith(vatId); @@ -486,8 +486,8 @@ describe('vat store methods', () => { const vatId = 'invalid' as VatId; const vref = 'o+42'; - expect(() => vatMethods.exportFromVat(vatId, vref)).toThrow( - 'not a valid VatId', + expect(() => vatMethods.exportFromEndpoint(vatId, vref)).toThrow( + 'not a valid EndpointId', ); }); @@ -495,7 +495,7 @@ describe('vat store methods', () => { const vatId = 'v1' as VatId; const vref = 'o-42'; // Import reference, not export - expect(() => vatMethods.exportFromVat(vatId, vref)).toThrow( + expect(() => vatMethods.exportFromEndpoint(vatId, vref)).toThrow( 'is not an export reference', ); }); @@ -504,8 +504,8 @@ describe('vat store methods', () => { const vatId = 'v1' as VatId; const vref = 'ko42'; // Kernel reference, not vat reference - expect(() => vatMethods.exportFromVat(vatId, vref)).toThrow( - 'is not a VRef', + expect(() => vatMethods.exportFromEndpoint(vatId, vref)).toThrow( + 'is not an ERef', ); }); }); diff --git a/packages/ocap-kernel/src/store/methods/vat.ts b/packages/ocap-kernel/src/store/methods/vat.ts index 8b98b7da0e..daa830796e 100644 --- a/packages/ocap-kernel/src/store/methods/vat.ts +++ b/packages/ocap-kernel/src/store/methods/vat.ts @@ -6,8 +6,8 @@ import { getObjectMethods } from './object.ts'; import { getPromiseMethods } from './promise.ts'; import { getReachableMethods } from './reachable.ts'; import { getRefCountMethods } from './refcount.ts'; -import { insistVatId } from '../../types.ts'; -import type { EndpointId, KRef, VatConfig, VatId, VRef } from '../../types.ts'; +import { insistEndpointId } from '../../types.ts'; +import type { EndpointId, KRef, VatConfig, VatId, ERef } from '../../types.ts'; import type { StoreContext, VatCleanupWork } from '../types.ts'; import { parseRef } from '../utils/parse-ref.ts'; import { parseReachableAndVatSlot } from '../utils/reachable.ts'; @@ -32,8 +32,12 @@ export function getVatMethods(ctx: StoreContext) { const { getPrefixedKeys, getSlotKey, getOwnerKey } = getBaseMethods(ctx.kv); const { deleteCListEntry } = getCListMethods(ctx); const { getReachableAndVatSlot } = getReachableMethods(ctx); - const { initKernelPromise, setPromiseDecider, getKernelPromise } = - getPromiseMethods(ctx); + const { + initKernelPromise, + setPromiseDecider, + getKernelPromise, + addPromiseSubscriber, + } = getPromiseMethods(ctx); const { initKernelObject } = getObjectMethods(ctx); const { addCListEntry } = getCListMethods(ctx); const { incrementRefCount, decrementRefCount } = getRefCountMethods(ctx); @@ -316,31 +320,34 @@ export function getVatMethods(ctx: StoreContext) { } /** - * Create the kernel's representation of an export from a vat. + * Create the kernel's representation of an export from an endpoint. * - * @param vatId - The vat doing the exporting. - * @param vref - The vat's ref for the entity in question. + * @param endpointId - The endpoint doing the exporting. + * @param eref - The endpoint's ref for the entity in question. * - * @returns the kref corresponding to the export of `vref` from `vatId`. + * @returns the kref corresponding to the export of `eref` from `endpointId`. */ - function exportFromVat(vatId: VatId, vref: VRef): KRef { - insistVatId(vatId); - const { isPromise, context, direction } = parseRef(vref); - assert(context === 'vat', `${vref} is not a VRef`); - assert(direction === 'export', `${vref} is not an export reference`); + function exportFromEndpoint(endpointId: EndpointId, eref: ERef): KRef { + insistEndpointId(endpointId); + const { isPromise, context, direction } = parseRef(eref); + assert(context === 'vat' || context === 'remote', `${eref} is not an ERef`); + assert(direction === 'export', `${eref} is not an export reference`); let kref; if (isPromise) { kref = initKernelPromise()[0]; - setPromiseDecider(kref, vatId); + setPromiseDecider(kref, endpointId); } else { - kref = initKernelObject(vatId); + kref = initKernelObject(endpointId); } - addCListEntry(vatId, kref, vref); + addCListEntry(endpointId, kref, eref); incrementRefCount(kref, 'export', { isExport: true, onlyRecognizable: true, }); - ctx.logger?.debug('exportFromVat', vatId, vref, kref); + ctx.logger?.debug('exportFromEndpoint', endpointId, eref, kref); + if (context === 'remote' && isPromise) { + addPromiseSubscriber(endpointId, kref); + } return kref; } @@ -359,7 +366,7 @@ export function getVatMethods(ctx: StoreContext) { isVatTerminated, cleanupTerminatedVat, nextTerminatedVatCleanup, - exportFromVat, isVatActive, + exportFromEndpoint, }; } diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 126ba11543..f83e46e701 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -3,6 +3,7 @@ import type { Message as SwingsetMessage, VatSyscallObject, VatSyscallSend, + VatOneResolution, } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; import type { VatCheckpoint } from '@metamask/kernel-store'; @@ -106,7 +107,7 @@ export type RunQueueItemSend = Infer; const RunQueueItemNotifyStruct = object({ type: literal('notify'), - vatId: string(), + endpointId: string(), kpid: string(), }); @@ -129,7 +130,7 @@ export const actionTypePriorities: GCActionType[] = [ const RunQueueItemGCActionStruct = object({ type: GCRunQueueTypeStruct, - vatId: string(), // VatId + endpointId: string(), // EndpointId krefs: array(string()), // KRefs }); @@ -137,7 +138,7 @@ export type RunQueueItemGCAction = Infer; const RunQueueItemBringOutYourDeadStruct = object({ type: literal('bringOutYourDead'), - vatId: string(), + endpointId: string(), }); export type RunQueueItemBringOutYourDead = Infer< @@ -208,6 +209,16 @@ export const isVatId = (value: unknown): value is VatId => value.at(0) === 'v' && value.slice(1) === String(Number(value.slice(1))); +export const isRemoteId = (value: unknown): value is RemoteId => + typeof value === 'string' && + value.at(0) === 'r' && + value.slice(1) === String(Number(value.slice(1))); + +export const isEndpointId = (value: unknown): value is EndpointId => + typeof value === 'string' && + (value.at(0) === 'v' || value.at(0) === 'r') && + value.slice(1) === String(Number(value.slice(1))); + /** * Assert that a value is a valid vat id. * @@ -215,7 +226,17 @@ export const isVatId = (value: unknown): value is VatId => * @throws if the value is not a valid vat id. */ export function insistVatId(value: unknown): asserts value is VatId { - isVatId(value) || Fail`not a valid VatId`; + isVatId(value) || Fail`not a valid VatId ${value}`; +} + +/** + * Assert that a value is a valid endpoint id. + * + * @param value - The value to check. + * @throws if the value is not a valid endpoint id. + */ +export function insistEndpointId(value: unknown): asserts value is EndpointId { + isEndpointId(value) || Fail`not a valid EndpointId`; } export const VatIdStruct = define('VatId', isVatId); @@ -242,11 +263,16 @@ export const VatMessageIdStruct = define( isVatMessageId, ); +export type RemoteMessageHandler = ( + from: string, + message: string, +) => Promise; + /** - * A "service" for managing vat workers. Abstracts platform-specific details of - * how vat workers are launched, terminated, and connected to the kernel. + * A service for things the kernel worker can't do itself. Abstracts platform-specific details of + * how vat workers are launched, terminated, and connected to the kernel, and for network communications. */ -export type VatWorkerService = { +export type PlatformServices = { /** * Launch a new worker with a specific vat id. * @@ -275,6 +301,43 @@ export type VatWorkerService = { * or rejects if there was an error during termination. */ terminateAll: () => Promise; + + /** + * Send a message over the network to another kernel. + * + * @param to - The network peer to whom to send the message. + * @param message - The message itself. + * @returns A promise that resolves when the message has been transmitted or + * rejects if there is some problem doing so. + */ + sendRemoteMessage: (to: string, message: string) => Promise; + /** + * Initialize network communications. + * + * @param keySeed - The seed for generating this kernel's secret key. + * @param knownRelays - Array of the peerIDs of relay nodes that can be used to listen for incoming + * connections from other kernels. + * @param remoteMessageHandler - A handler function to receive remote messages. + * @returns A promise that resolves once network access has been established + * or rejects if there is some problem doing so. + */ + initializeRemoteComms: ( + keySeed: string, + knownRelays: string[], + remoteMessageHandler: RemoteMessageHandler, + ) => Promise; +}; + +export type SendRemoteMessage = ( + peerId: string, + message: string, +) => Promise; + +export type RemoteComms = { + getPeerId: () => string; + sendRemoteMessage: SendRemoteMessage; + issueOcapURL: (kref: string) => Promise; + redeemLocalOcapURL: (ocapURL: string) => Promise; }; // Cluster configuration @@ -381,14 +444,14 @@ export function insistGCActionType( isGCActionType(value) || Fail`not a valid GCActionType ${value}`; } -export type GCAction = `${VatId} ${GCActionType} ${KRef}`; +export type GCAction = `${EndpointId} ${GCActionType} ${KRef}`; export const GCActionStruct = define('GCAction', (value: unknown) => { if (typeof value !== 'string') { return false; } - const [vatId, actionType, kref] = value.split(' '); - if (!isVatId(vatId)) { + const [endpointId, actionType, kref] = value.split(' '); + if (!isEndpointId(endpointId)) { return false; } if (!isGCActionType(actionType)) { @@ -404,9 +467,18 @@ export const isGCAction = (value: unknown): value is GCAction => is(value, GCActionStruct); export type CrankResults = { - didDelivery?: VatId; // the vat on which we made a delivery + didDelivery?: EndpointId; // the endpoint to which we made a delivery abort?: boolean; // changes should be discarded, not committed terminate?: { vatId: VatId; reject: boolean; info: SwingSetCapData }; }; export type VatDeliveryResult = [VatCheckpoint, string | null]; + +export type EndpointHandle = { + deliverMessage: (target: ERef, message: Message) => Promise; + deliverNotify: (resolutions: VatOneResolution[]) => Promise; + deliverDropExports: (erefs: ERef[]) => Promise; + deliverRetireExports: (erefs: ERef[]) => Promise; + deliverRetireImports: (erefs: ERef[]) => Promise; + deliverBringOutYourDead: () => Promise; +}; diff --git a/packages/ocap-kernel/tsconfig.build.json b/packages/ocap-kernel/tsconfig.build.json index 4a231e4c00..fbda55b3a5 100644 --- a/packages/ocap-kernel/tsconfig.build.json +++ b/packages/ocap-kernel/tsconfig.build.json @@ -4,7 +4,7 @@ "baseUrl": "./", "outDir": "./dist", "rootDir": "./src", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "types": ["ses"] }, "references": [ diff --git a/packages/ocap-kernel/vitest.config.ts b/packages/ocap-kernel/vitest.config.ts index 4d6787e6c2..bc92879b77 100644 --- a/packages/ocap-kernel/vitest.config.ts +++ b/packages/ocap-kernel/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel', - setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), + setupFiles: path.resolve(__dirname, '../nodejs/src/env/endoify.ts'), }, }), ); diff --git a/packages/test-utils/src/env/mock-kernel.ts b/packages/test-utils/src/env/mock-kernel.ts index 4e7364ba44..2786dbf39b 100644 --- a/packages/test-utils/src/env/mock-kernel.ts +++ b/packages/test-utils/src/env/mock-kernel.ts @@ -69,7 +69,7 @@ export const setupOcapKernelMock = (): { params: literal(null), }), }), - VatWorkerServiceCommandMethod: { + PlatformServicesCommandMethod: { launch: 'launch', terminate: 'terminate', terminateAll: 'terminateAll', diff --git a/vitest.config.ts b/vitest.config.ts index 922e9e3bc5..f53c58957c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -87,10 +87,10 @@ export default defineConfig({ lines: 1.96, }, 'packages/kernel-browser-runtime/**': { - statements: 79.32, - functions: 72.13, - branches: 69.38, - lines: 79.32, + statements: 70.99, + functions: 67.69, + branches: 54.23, + lines: 70.99, }, 'packages/kernel-errors/**': { statements: 98.73, @@ -137,7 +137,7 @@ export default defineConfig({ 'packages/kernel-utils/**': { statements: 100, functions: 100, - branches: 100, + branches: 96.55, lines: 100, }, 'packages/logger/**': { @@ -147,16 +147,16 @@ export default defineConfig({ lines: 100, }, 'packages/nodejs/**': { - statements: 82.45, - functions: 85.71, - branches: 85.71, - lines: 83.92, + statements: 68.49, + functions: 75, + branches: 54.54, + lines: 69.44, }, 'packages/ocap-kernel/**': { - statements: 92.62, - functions: 95.01, - branches: 82.6, - lines: 92.59, + statements: 87.97, + functions: 89.26, + branches: 80, + lines: 87.98, }, 'packages/remote-iterables/**': { statements: 100, diff --git a/yarn.config.cjs b/yarn.config.cjs index a1901b2340..90854c9d6b 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -648,7 +648,8 @@ function expectValidVersionRanges(Yarn, workspace) { return ( semver.validRange(range) || range === 'workspace:^' || - range === 'workspace:~' + range === 'workspace:~' || + range.startsWith('patch:') ); }; const dependencies = Yarn.dependencies({ workspace }); diff --git a/yarn.lock b/yarn.lock index fb68709532..37a784ee98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -449,7 +449,7 @@ __metadata: languageName: node linkType: hard -"@chainsafe/libp2p-yamux@npm:^6.0.2": +"@chainsafe/libp2p-yamux@npm:6.0.2": version: 6.0.2 resolution: "@chainsafe/libp2p-yamux@npm:6.0.2" dependencies: @@ -464,6 +464,21 @@ __metadata: languageName: node linkType: hard +"@chainsafe/libp2p-yamux@patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch": + version: 6.0.2 + resolution: "@chainsafe/libp2p-yamux@patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch::version=6.0.2&hash=aece86" + dependencies: + "@libp2p/interface": "npm:^1.1.3" + "@libp2p/utils": "npm:^5.2.5" + get-iterator: "npm:^2.0.1" + it-foreach: "npm:^2.0.6" + it-pipe: "npm:^3.0.1" + it-pushable: "npm:^3.2.3" + uint8arraylist: "npm:^2.4.8" + checksum: 10/6baa377ef22a964b0084760380507f49029992e7d89fc151ca21565101e8bd596a3f2cfdadfa08089294b3c196fc739cfc4718fe449328d059138be13b94c53a + languageName: node + linkType: hard + "@chainsafe/netmask@npm:^2.0.0": version: 2.0.0 resolution: "@chainsafe/netmask@npm:2.0.0" @@ -2328,6 +2343,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/captp": "npm:^4.4.8" + "@endo/errors": "npm:^1.2.13" "@endo/exo": "npm:^1.5.12" "@endo/patterns": "npm:^1.7.0" "@endo/promise-kit": "npm:^1.1.13" @@ -2446,11 +2462,22 @@ __metadata: resolution: "@metamask/ocap-kernel@workspace:packages/ocap-kernel" dependencies: "@agoric/swingset-liveslots": "npm:0.10.3-u21.0.1" + "@chainsafe/libp2p-noise": "npm:^16.1.3" + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch" "@endo/errors": "npm:^1.2.13" "@endo/import-bundle": "npm:^1.5.2" "@endo/marshal": "npm:^1.8.0" "@endo/pass-style": "npm:^1.6.3" "@endo/promise-kit": "npm:^1.1.13" + "@libp2p/bootstrap": "npm:^11.0.35" + "@libp2p/circuit-relay-v2": "npm:^3.2.11" + "@libp2p/crypto": "npm:^5.1.1" + "@libp2p/identify": "npm:^3.0.29" + "@libp2p/interface": "npm:^2.9.0" + "@libp2p/peer-id": "npm:^5.1.2" + "@libp2p/webrtc": "npm:^5.2.12" + "@libp2p/websockets": "npm:^9.2.10" + "@libp2p/webtransport": "npm:^5.0.40" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -2464,6 +2491,7 @@ __metadata: "@metamask/streams": "workspace:^" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.4.2" + "@multiformats/multiaddr": "npm:^12.3.0" "@ocap/cli": "workspace:^" "@ocap/kernel-platforms": "workspace:^" "@ocap/test-utils": "workspace:^" @@ -2481,12 +2509,16 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + it-byte-stream: "npm:^2.0.1" + libp2p: "npm:^2.8.5" + multiformats: "npm:^13.3.7" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" ses: "npm:^1.14.0" setimmediate: "npm:^1.0.5" typescript: "npm:~5.8.2" typescript-eslint: "npm:^8.29.0" + uint8arrays: "npm:^5.1.0" vite: "npm:^7.1.2" vitest: "npm:^3.2.4" languageName: unknown @@ -3018,7 +3050,7 @@ __metadata: resolution: "@ocap/brow-2-brow@workspace:packages/brow-2-brow" dependencies: "@chainsafe/libp2p-noise": "npm:^16.1.3" - "@chainsafe/libp2p-yamux": "npm:^6.0.2" + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A6.0.2#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-6.0.2-ca843b3f79.patch" "@libp2p/autonat": "npm:^2.0.30" "@libp2p/bootstrap": "npm:^11.0.35" "@libp2p/circuit-relay-v2": "npm:^3.2.11" @@ -3306,7 +3338,6 @@ __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/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" @@ -3402,6 +3433,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/promise-kit": "npm:^1.1.13" + "@libp2p/webrtc": "npm:^5.2.12" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -11029,10 +11061,10 @@ __metadata: languageName: node linkType: hard -"multiformats@npm:^13.0.0, multiformats@npm:^13.1.0, multiformats@npm:^13.3.1": - version: 13.3.2 - resolution: "multiformats@npm:13.3.2" - checksum: 10/457bcf4f1c14c59a553adad234fb1f0d82d15877b2d1c265e7f306d69824473c74b6e7a489eb88968b0db1f2c688ef64d9332a4485ef154cd2a68150ea61028c +"multiformats@npm:^13.0.0, multiformats@npm:^13.1.0, multiformats@npm:^13.3.1, multiformats@npm:^13.3.7": + version: 13.4.0 + resolution: "multiformats@npm:13.4.0" + checksum: 10/95a796c0c7d99862d588263ebaa2285aa66a5c1d8915356c17d6863749f7fee28407fb9e16b78411c3d2977780c8e87a37a3f88716894dbda63c18a32e229347 languageName: node linkType: hard